@brftech/filex-core 0.42.0 → 0.42.2

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 (40) hide show
  1. package/dist/{ArchiveViewer-CGl_J_qE.js → ArchiveViewer-BUQWLhwX.js} +2 -2
  2. package/dist/{ArchiveViewer-CGl_J_qE.js.map → ArchiveViewer-BUQWLhwX.js.map} +1 -1
  3. package/dist/{CsvViewer-D-na6SEG.js → CsvViewer-Bg2_aqzc.js} +2 -2
  4. package/dist/{CsvViewer-D-na6SEG.js.map → CsvViewer-Bg2_aqzc.js.map} +1 -1
  5. package/dist/{DrawioViewer-BFkTSrNc.js → DrawioViewer-CC6rJXeP.js} +2 -2
  6. package/dist/{DrawioViewer-BFkTSrNc.js.map → DrawioViewer-CC6rJXeP.js.map} +1 -1
  7. package/dist/{EpubViewer-BfRE0DTL.js → EpubViewer-CAoTSXTO.js} +2 -2
  8. package/dist/{EpubViewer-BfRE0DTL.js.map → EpubViewer-CAoTSXTO.js.map} +1 -1
  9. package/dist/{IpynbViewer-BpBlICH-.js → IpynbViewer-D0GwGpnE.js} +2 -2
  10. package/dist/{IpynbViewer-BpBlICH-.js.map → IpynbViewer-D0GwGpnE.js.map} +1 -1
  11. package/dist/{MermaidViewer-Ry5Ma463.js → MermaidViewer-DGwAvRZg.js} +2 -2
  12. package/dist/{MermaidViewer-Ry5Ma463.js.map → MermaidViewer-DGwAvRZg.js.map} +1 -1
  13. package/dist/{PsdViewer-BmTlq1Gh.js → PsdViewer-DhGweA0m.js} +2 -2
  14. package/dist/{PsdViewer-BmTlq1Gh.js.map → PsdViewer-DhGweA0m.js.map} +1 -1
  15. package/dist/{TiffViewer-DE5bKDDx.js → TiffViewer-D0AurvAh.js} +2 -2
  16. package/dist/{TiffViewer-DE5bKDDx.js.map → TiffViewer-D0AurvAh.js.map} +1 -1
  17. package/dist/{Viewer3D-DefcNYOm.js → Viewer3D-CdZeK4j4.js} +2 -2
  18. package/dist/{Viewer3D-DefcNYOm.js.map → Viewer3D-CdZeK4j4.js.map} +1 -1
  19. package/dist/filex-core.js +1 -1
  20. package/dist/filex-core.umd.cjs +50 -50
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/{index-B0N5qiyj.js → index-BArI-B1M.js} +9818 -9725
  23. package/dist/index-BArI-B1M.js.map +1 -0
  24. package/dist/index.d.ts +14 -0
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +165 -90
  28. package/src/components/HomeView.vue +6 -3
  29. package/src/components/RecentlyOpened.vue +16 -2
  30. package/src/components/S3KeysPanel.vue +33 -27
  31. package/src/components/SSHKeysPanel.vue +29 -25
  32. package/src/components/SideNav.vue +22 -0
  33. package/src/components/TokensPanel.vue +26 -22
  34. package/src/composables/useScrolledX.ts +21 -0
  35. package/src/lib/nodeRow.ts +106 -0
  36. package/src/locales/en.ts +8 -2
  37. package/src/locales/tr.ts +4 -3
  38. package/src/styles/base.css +24 -0
  39. package/src/types/FileNode.ts +8 -0
  40. package/dist/index-B0N5qiyj.js.map +0 -1
@@ -242,6 +242,16 @@ const views = computed(() => {
242
242
 
243
243
  const writable = computed(() => props.canWrite !== false);
244
244
 
245
+ /**
246
+ * The storage the listing is in is mounted read-only. Its "+ New" menu is not
247
+ * disabled but GONE: on a read-only mount there is no folder where any of
248
+ * its rows could ever work, and a disabled menu invites the question a menu
249
+ * cannot answer. The row itself says why — see the tag on it (issue #30).
250
+ */
251
+ const activeReadOnly = computed(
252
+ () => !!props.activeStorage && props.storages.some((s) => s.name === props.activeStorage && s.readOnly === true),
253
+ );
254
+
245
255
  /**
246
256
  * Which row reads as the one you are standing on.
247
257
  *
@@ -426,6 +436,7 @@ const toggleLabel = computed(() => t('sidenav.close'));
426
436
  stand here behind `v-if="!newMenu"` (Upload + New folder) is gone:
427
437
  both verbs are the first two rows of this menu. -->
428
438
  <button
439
+ v-if="!activeReadOnly"
429
440
  ref="newBtnEl"
430
441
  type="button"
431
442
  class="fe-sidenav__new"
@@ -693,6 +704,17 @@ const toggleLabel = computed(() => t('sidenav.close'));
693
704
  :aria-label="t('sidenav.storage.shared')"
694
705
  >{{ t('sidenav.storage.shared') }}</span
695
706
  >
707
+ <!-- A read-only mount says so on the row, where the user looks
708
+ for it. The admin list had an "RO" badge; the people it
709
+ applied to never saw one (issue #30). -->
710
+ <span
711
+ v-if="showLabels && s.readOnly"
712
+ class="fe-sidenav__tag"
713
+ role="img"
714
+ data-testid="sidenav-storage-readonly"
715
+ :aria-label="t('sidenav.storage.readOnly')"
716
+ >{{ t('sidenav.storage.readOnly') }}</span
717
+ >
696
718
  </button>
697
719
  </li>
698
720
  </ul>
@@ -30,6 +30,7 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
30
30
  import type { ApiToken } from '../types/Tokens';
31
31
  import { useLocale } from '../composables/useLocale';
32
32
  import { useTokens } from '../composables/useTokens';
33
+ import { useScrolledX } from '../composables/useScrolledX';
33
34
  import { resolveLocale } from '../locales/resolve';
34
35
 
35
36
  const props = defineProps<{
@@ -61,6 +62,7 @@ const emit = defineEmits<{
61
62
 
62
63
  const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
63
64
  const { t, formatDate } = useLocale(locale);
65
+ const { scrolledX, onScroll } = useScrolledX();
64
66
 
65
67
  const { tokens, loading, error, canMint, revealed, load, create, remove, dismiss } = useTokens(
66
68
  props.config,
@@ -288,28 +290,30 @@ function usedLabel(row: ApiToken): string {
288
290
  </div>
289
291
 
290
292
  <p v-if="loading" class="fe-s3keys__muted">…</p>
291
- <table v-else-if="tokens.length" class="fe-s3keys__table">
292
- <thead>
293
- <tr>
294
- <th>{{ t('conn.tokens.col.label') }}</th>
295
- <th>{{ t('conn.tokens.col.scopes') }}</th>
296
- <th>{{ t('conn.tokens.col.used') }}</th>
297
- <th></th>
298
- </tr>
299
- </thead>
300
- <tbody>
301
- <tr v-for="row in tokens" :key="row.id">
302
- <td>{{ row.label || '—' }}</td>
303
- <td><code>{{ row.scopes }}</code></td>
304
- <td>{{ usedLabel(row) }}</td>
305
- <td class="fe-s3keys__actions">
306
- <button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(row)">
307
- {{ confirming === row.id ? t('conn.tokens.confirm') : t('conn.tokens.revoke') }}
308
- </button>
309
- </td>
310
- </tr>
311
- </tbody>
312
- </table>
293
+ <div v-else-if="tokens.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
294
+ <table class="fe-s3keys__table">
295
+ <thead>
296
+ <tr>
297
+ <th>{{ t('conn.tokens.col.label') }}</th>
298
+ <th>{{ t('conn.tokens.col.scopes') }}</th>
299
+ <th>{{ t('conn.tokens.col.used') }}</th>
300
+ <th></th>
301
+ </tr>
302
+ </thead>
303
+ <tbody>
304
+ <tr v-for="row in tokens" :key="row.id">
305
+ <td>{{ row.label || '—' }}</td>
306
+ <td><code>{{ row.scopes }}</code></td>
307
+ <td>{{ usedLabel(row) }}</td>
308
+ <td class="fe-s3keys__actions">
309
+ <button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(row)">
310
+ {{ confirming === row.id ? t('conn.tokens.confirm') : t('conn.tokens.revoke') }}
311
+ </button>
312
+ </td>
313
+ </tr>
314
+ </tbody>
315
+ </table>
316
+ </div>
313
317
  <p v-else-if="canMint" class="fe-s3keys__muted">{{ t('conn.tokens.empty') }}</p>
314
318
 
315
319
  <!-- ⚠ Said next to the button rather than in a document nobody opens: a
@@ -0,0 +1,21 @@
1
+ import { ref } from 'vue';
2
+
3
+ /**
4
+ * "Is this box scrolled sideways at all?" — the one fact a pinned column
5
+ * needs in order to draw its left divider only while something is actually
6
+ * sliding underneath it. A permanent divider would claim the column is
7
+ * floating when it is simply the last cell of a table that fits.
8
+ *
9
+ * Bind `onScroll` to the scroll container and `scrolledX` to its
10
+ * `is-scrolled-x` class; the stylesheet does the rest (base.css,
11
+ * `.fe-s3keys__scroll.is-scrolled-x`).
12
+ */
13
+ export function useScrolledX() {
14
+ const scrolledX = ref(false);
15
+ function onScroll(ev: Event) {
16
+ const el = ev.currentTarget as HTMLElement | null;
17
+ const on = (el?.scrollLeft ?? 0) > 0;
18
+ if (on !== scrolledX.value) scrolledX.value = on;
19
+ }
20
+ return { scrolledX, onScroll };
21
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * nodeRow — the starred / recently-opened / tag endpoints answer with raw node
3
+ * rows (relative `path`, numeric `storage_id`, RFC3339 stamps), not the
4
+ * listing shape. This turns one row into the `FileNode` every view renders.
5
+ *
6
+ * Pure on purpose: the explorer wraps it with its own config, and the unit
7
+ * test pins the two facts the context menu depends on — `perm` and
8
+ * `read_only` — without mounting the component.
9
+ */
10
+ import type { FileNode } from '../types';
11
+
12
+ /** What the explorer knows about the install, as far as a row needs it. */
13
+ export interface NodeRowContext {
14
+ /** `config.storages` — names, and the host's read-only flag per storage. */
15
+ storages: ReadonlyArray<{ name: string; readOnly?: boolean }>;
16
+ /** Multi-storage root mode: a row that cannot be addressed by storage name
17
+ * is dropped rather than guessed into somebody else's drive. */
18
+ multiStorageRoot: boolean;
19
+ }
20
+
21
+ const PERM_LEVELS = new Set(['none', 'viewer', 'editor', 'owner']);
22
+
23
+ /** tablo:t1 — an RFC3339 stamp from a node row as unix ms, or undefined. A
24
+ * string we cannot parse is left undefined rather than turned into `NaN`,
25
+ * which would print as "Invalid Date" and sort unpredictably. */
26
+ export function rowMillis(v: unknown): number | undefined {
27
+ if (typeof v !== 'string' || !v) return undefined;
28
+ const ms = Date.parse(v);
29
+ return Number.isFinite(ms) ? ms : undefined;
30
+ }
31
+
32
+ /**
33
+ * The storage NAME is what a qualified path needs, and a node row does not
34
+ * carry the id-to-name mapping. The backend fills `storage` for exactly this
35
+ * (handlers/meta.go, `(*Meta).rows`); against an older server the only safe
36
+ * fallback is the single-storage case — guessing in a multi-storage install
37
+ * sends the user to a path in somebody else's drive.
38
+ */
39
+ export function nodeRowToFileNode(
40
+ row: Record<string, unknown>,
41
+ ctx: NodeRowContext,
42
+ ): FileNode | null {
43
+ const rel = String(row?.path ?? '').replace(/^\/+/, '');
44
+ if (!rel) return null;
45
+ const configured = ctx.storages ?? [];
46
+ const storageName =
47
+ typeof row.storage === 'string' && row.storage
48
+ ? row.storage
49
+ : configured.length === 1
50
+ ? configured[0].name
51
+ : '';
52
+ if (ctx.multiStorageRoot && !storageName) return null;
53
+ const name = String(row.name ?? rel.split('/').pop() ?? '');
54
+ const isDir = row.type === 'dir';
55
+ const size = typeof row.size === 'number' ? row.size : 0;
56
+ const id = typeof row.id === 'number' ? row.id : undefined;
57
+ /* ⚠⚠ THE CONTEXT MENU MUST BE THE SAME EVERYWHERE (owner, 2026-09-19). The
58
+ menu gates Rename / Delete / Move to / Share on the row's own `perm` and
59
+ on the storage's `read_only` — a folder listing carries both, and until
60
+ these two lines a Recent / Starred / tag / Home row carried neither, so
61
+ the menu fell back to the folder opened LAST: nothing on the landing page
62
+ (every write verb gone), or a stale writable folder (every write verb
63
+ offered on a read-only mount). The row says for itself what may be done
64
+ to it, because in these views there is no folder to ask.
65
+ `read_only` prefers the server's word and falls back to the host's own
66
+ storage list for a server that predates the field. */
67
+ const perm =
68
+ typeof row.perm === 'string' && PERM_LEVELS.has(row.perm)
69
+ ? (row.perm as FileNode['perm'])
70
+ : undefined;
71
+ const hostReadOnly = configured.find((s) => s.name === storageName)?.readOnly === true;
72
+ const readOnly = typeof row.read_only === 'boolean' ? row.read_only : hostReadOnly;
73
+ return {
74
+ type: isDir ? 'dir' : 'file',
75
+ id,
76
+ /* tablo:t1 — ⚠⚠ THE DATE. A node row carries `backend_mtime` (what the
77
+ storage says) and `db_mtime` (what our last scan recorded) as RFC3339
78
+ strings; `FileNode.last_modified` is unix MILLISECONDS. Nothing mapped
79
+ between the two, so every row from Recent, Starred and a tag view
80
+ arrived with no date at all — measured on Recent: eleven rows, eleven em
81
+ dashes in the Modified column, and a Modified column header you could
82
+ click that then sorted nothing. It also made the date grouping this view
83
+ is supposed to show impossible, because every row fell in the "No date"
84
+ bucket. Storage first: `backend_mtime` is the file's own truth and
85
+ `db_mtime` only says when we last looked at it. */
86
+ last_modified: rowMillis(row.backend_mtime) ?? rowMillis(row.db_mtime),
87
+ path: storageName ? `${storageName}://${rel}` : rel,
88
+ basename: name,
89
+ extension: isDir
90
+ ? ''
91
+ : name.includes('.')
92
+ ? (name.split('.').pop() || '').toLowerCase()
93
+ : '',
94
+ storage: storageName,
95
+ visibility: 'private',
96
+ size,
97
+ file_size: size,
98
+ mime_type: typeof row.mime === 'string' ? row.mime : '',
99
+ ...(perm ? { perm } : {}),
100
+ read_only: readOnly,
101
+ // Keyed by node id. A file with no rendered thumbnail 404s here and the
102
+ // view falls back to its icon — the contract the ordinary listing has too.
103
+ thumb_url: !isDir && id !== undefined ? `/api/files/thumb/${id}` : undefined,
104
+ extra_metadata: {},
105
+ } as unknown as FileNode;
106
+ }
package/src/locales/en.ts CHANGED
@@ -983,7 +983,7 @@ export const en: Record<string, string> = {
983
983
  'storages.fields.share': 'Share',
984
984
  'storages.fields.domain': 'Domain / workgroup',
985
985
  'storages.fields.dialTimeout': 'Connect timeout (seconds)',
986
- 'storages.fieldHelp.disablePresign': 'Turn on when the store rejects SDK-signed URLs (Ceph RGW / some Hetzner setups answer SignatureDoesNotMatch); uploads then stream through the backend.',
986
+ 'storages.fieldHelp.disablePresign': 'On by default: uploads and the downloads behind public share links stream through filex, so the bucket endpoint never has to be reachable from a browser (a LAN-only MinIO just works). Turn it off only when the endpoint is reachable from your users\' browsers (AWS, a public MinIO) and you want them to talk to the bucket directly — faster for very large files, and the store must accept SDK-signed URLs (Ceph RGW / some Hetzner setups answer SignatureDoesNotMatch).',
987
987
  'storages.fieldHelp.endpoint': 'Leave empty for AWS S3. Any S3-compatible store needs its endpoint.',
988
988
  'storages.fieldHelp.hostKey': 'A single public key in authorized_keys / known_hosts line form.',
989
989
  'storages.fieldHelp.insecureSkipHostKey': 'Accepts any host key. Only for throwaway hosts.',
@@ -1047,6 +1047,7 @@ export const en: Record<string, string> = {
1047
1047
  // "expand" would promise a width change to something that has no width.
1048
1048
  'sidenav.open': 'Open navigation',
1049
1049
  'sidenav.storage.shared': 'Shared',
1050
+ 'sidenav.storage.readOnly': 'Read-only',
1050
1051
  // ⚠ `toolbar.nav` is GONE, and nothing replaced it. It was the one label on
1051
1052
  // the panel's collapse control, first as the noun "Navigation" — which names
1052
1053
  // the panel and never says the button does anything to it, so the owner
@@ -1121,8 +1122,13 @@ export const en: Record<string, string> = {
1121
1122
  'newdoc.create': 'Create',
1122
1123
  'newdoc.creating': 'Creating…',
1123
1124
  'newdoc.cancel': 'Cancel',
1125
+ // ⚠ The top search field searches the WHOLE storage (and every storage at
1126
+ // the root) — never only the open folder. The scope named here is therefore
1127
+ // the storage, not the folder; "Filter in this folder…" (FilterBar) is the
1128
+ // folder-scoped one. It used to say "Search in Photos" for a folder called
1129
+ // Photos, which was a lie (2026-09-19).
1124
1130
  'drive.search.placeholder': 'Search in {scope}',
1125
- 'drive.search.placeholder_all': 'Search your files',
1131
+ 'drive.search.placeholder_all': 'Search all storages',
1126
1132
  'drive.search.hint_title': 'Search everywhere and run commands ({combo})',
1127
1133
  'drive.storage.used': '{used} of {total} used',
1128
1134
  'drive.storage.used_unlimited': '{used} used',
package/src/locales/tr.ts CHANGED
@@ -983,7 +983,7 @@ export const tr: Record<string, string> = {
983
983
  'storages.fields.share': 'Paylaşım',
984
984
  'storages.fields.domain': 'Etki alanı / çalışma grubu',
985
985
  'storages.fields.dialTimeout': 'Bağlanma zaman aşımı (saniye)',
986
- 'storages.fieldHelp.disablePresign': 'Depo, SDK\'nın imzaladığı URL\'leri reddediyorsa açın (Ceph RGW / bazı Hetzner kurulumları SignatureDoesNotMatch döner); yüklemeler o zaman arka uç üzerinden akar.',
986
+ 'storages.fieldHelp.disablePresign': 'Varsayılan olarak açık: yüklemeler ve herkese açık paylaşım bağlantılarındaki indirmeler filex üzerinden akar; kova endpoint\'inin tarayıcıdan erişilebilir olması gerekmez (yalnız yerel ağdaki MinIO doğrudan çalışır). Yalnızca endpoint kullanıcıların tarayıcısından erişilebiliyorsa (AWS, herkese açık MinIO) ve tarayıcının kovayla doğrudan konuşmasını istiyorsanız kapatın — çok büyük dosyalarda daha hızlıdır, ama depo SDK\'nın imzaladığı URL\'leri kabul etmelidir (Ceph RGW / bazı Hetzner kurulumları SignatureDoesNotMatch döner).',
987
987
  'storages.fieldHelp.endpoint': 'AWS S3 için boş bırakın. S3 uyumlu diğer tüm depolar kendi endpoint\'ini ister.',
988
988
  'storages.fieldHelp.hostKey': 'authorized_keys / known_hosts satır biçiminde tek bir açık anahtar.',
989
989
  'storages.fieldHelp.insecureSkipHostKey': 'Her sunucu anahtarını kabul eder. Yalnızca geçici sunucular için.',
@@ -1047,6 +1047,7 @@ export const tr: Record<string, string> = {
1047
1047
  // genişliği büyütmeyi vaat ederdi.
1048
1048
  'sidenav.open': 'Gezintiyi aç',
1049
1049
  'sidenav.storage.shared': 'Paylaşılan',
1050
+ 'sidenav.storage.readOnly': 'Salt okunur',
1050
1051
  // ⚠ `toolbar.nav` KALDIRILDI, yerine bir şey konmadı — bkz. en.ts'teki not.
1051
1052
  // Paneli daraltan tek denetimin etiketiydi; önce "Gezinti" (panelin adı,
1052
1053
  // düğmenin ne yaptığını söylemiyor), sonra "Gezintiyi göster/gizle" (sabit
@@ -1119,8 +1120,8 @@ export const tr: Record<string, string> = {
1119
1120
  'newdoc.create': 'Oluştur',
1120
1121
  'newdoc.creating': 'Oluşturuluyor…',
1121
1122
  'newdoc.cancel': 'Vazgeç',
1122
- 'drive.search.placeholder': '{scope} içinde ara',
1123
- 'drive.search.placeholder_all': 'Dosyalarında ara',
1123
+ 'drive.search.placeholder': '{scope} deposunda ara',
1124
+ 'drive.search.placeholder_all': 'Tüm depolarda ara',
1124
1125
  'drive.search.hint_title': 'Her yerde ara ve komut çalıştır ({combo})',
1125
1126
  'drive.storage.used': '{total} alanın {used} kadarı dolu',
1126
1127
  'drive.storage.used_unlimited': '{used} kullanılıyor',
@@ -10449,6 +10449,30 @@ filex-explorer {
10449
10449
  box-shadow: -1px 0 0 0 var(--fe-border);
10450
10450
  }
10451
10451
 
10452
+ /* The key/token tables in the connection panels (S3KeysPanel, SSHKeysPanel
10453
+ and TokensPanel share `.fe-s3keys__*`) follow the same model as the list
10454
+ view above: the table is as wide as its content and the panel scrolls
10455
+ sideways, the actions cell rides the right edge over the columns sliding
10456
+ under it, carrying the panel's own ground (`--fe-bg-elev`, what
10457
+ `.fe-s3keys` paints), and its divider appears only while something is
10458
+ scrolled underneath (`useScrolledX` flips the class). One rule here rather
10459
+ than three copies in the panels, so they cannot drift apart. */
10460
+ .fe-s3keys__scroll {
10461
+ overflow-x: auto;
10462
+ }
10463
+ .fe-s3keys__scroll .fe-s3keys__table {
10464
+ min-width: max-content;
10465
+ }
10466
+ .fe-s3keys__table .fe-s3keys__actions {
10467
+ position: sticky;
10468
+ right: 0;
10469
+ z-index: 1;
10470
+ background: var(--fe-bg-elev);
10471
+ }
10472
+ .fe-s3keys__scroll.is-scrolled-x .fe-s3keys__actions {
10473
+ box-shadow: -1px 0 0 0 var(--fe-border);
10474
+ }
10475
+
10452
10476
  /* The date headings travel with the horizontal scroll rather than sliding off
10453
10477
  the left edge — they are `max-content` wide so sticky has somewhere to move
10454
10478
  them to (a full-width block has none). */
@@ -42,6 +42,14 @@ export interface FileNode {
42
42
  * projectFileNodes emits it when a storage has RBAC on). '' / undefined =
43
43
  * ACL not enforced. Used to gate edit/manage affordances client-side. */
44
44
  perm?: 'none' | 'viewer' | 'editor' | 'owner';
45
+ /** The row sits on a read-only storage. Carried by the rows that come from
46
+ * OUTSIDE a folder listing — Recent, Starred, a tag view, the Home cards,
47
+ * the Recently-opened tray (`handlers/meta.go`) — because there the
48
+ * listing-level `read_only` has no folder to describe. A folder listing's
49
+ * rows leave it unset; `dirReadOnly` answers for the whole folder. Folded
50
+ * into the write gate with `perm`, so the context menu is the same in
51
+ * every view. */
52
+ read_only?: boolean;
45
53
  /* wiring:e2 — dir rows: true when the folder is E2E-encrypted (carries a
46
54
  * `.filex-e2e.json` marker). Drives the 🔒 badge in the listings. */
47
55
  e2e?: boolean;