@brftech/filex-core 0.31.0 → 0.33.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 (62) hide show
  1. package/README.md +26 -2
  2. package/dist/{CsvViewer-DxujFEM3.js → CsvViewer-CqWeV8VO.js} +22 -22
  3. package/dist/CsvViewer-CqWeV8VO.js.map +1 -0
  4. package/dist/{DrawioViewer-DCvWnX2t.js → DrawioViewer-BNALOB04.js} +16 -16
  5. package/dist/DrawioViewer-BNALOB04.js.map +1 -0
  6. package/dist/filex-core.js +9346 -8069
  7. package/dist/filex-core.js.map +1 -1
  8. package/dist/filex-core.umd.cjs +49 -49
  9. package/dist/filex-core.umd.cjs.map +1 -1
  10. package/dist/index.d.ts +235 -16
  11. package/dist/style.css +1 -1
  12. package/package.json +1 -1
  13. package/src/FileExplorer.vue +776 -246
  14. package/src/components/CommandPalette.vue +21 -5
  15. package/src/components/ConnectionsPanel.vue +2 -1
  16. package/src/components/E2eRecoveryUnlockModal.vue +29 -4
  17. package/src/components/FilterBar.vue +244 -0
  18. package/src/components/GalleryView.vue +12 -1
  19. package/src/components/GridView.vue +48 -5
  20. package/src/components/InspectorPanel.vue +231 -8
  21. package/src/components/ListView.vue +12 -3
  22. package/src/components/NFSExportsPanel.vue +2 -1
  23. package/src/components/PendingOpsTray.vue +1 -1
  24. package/src/components/PresenceBar.vue +3 -2
  25. package/src/components/S3KeysPanel.vue +2 -1
  26. package/src/components/SSHKeysPanel.vue +2 -1
  27. package/src/components/SecondaryPane.vue +28 -13
  28. package/src/components/SideNav.vue +161 -1
  29. package/src/components/StarButton.vue +2 -1
  30. package/src/components/TabBar.vue +2 -2
  31. package/src/components/ThemeGallery.vue +1 -1
  32. package/src/components/TokensPanel.vue +2 -1
  33. package/src/components/Toolbar.vue +244 -58
  34. package/src/components/ViewSwitcher.vue +81 -0
  35. package/src/composables/useFileApi.ts +49 -2
  36. package/src/composables/useLocale.ts +1 -1
  37. package/src/composables/useOperations.ts +3 -3
  38. package/src/composables/usePendingOps.ts +1 -1
  39. package/src/composables/useTabs.ts +1 -1
  40. package/src/composables/useUploadChunked.ts +39 -7
  41. package/src/index.ts +8 -2
  42. package/src/lib/e2ecrypto.ts +191 -3
  43. package/src/lib/fileFilters.ts +143 -0
  44. package/src/lib/listing.ts +47 -0
  45. package/src/lib/themes.ts +7 -7
  46. package/src/lib/transfer.ts +6 -6
  47. package/src/locales/en.ts +115 -0
  48. package/src/locales/index.ts +1 -0
  49. package/src/locales/resolve.ts +66 -0
  50. package/src/locales/tr.ts +116 -0
  51. package/src/modals/ConvertModal.vue +14 -12
  52. package/src/modals/NewFolderModal.vue +1 -1
  53. package/src/modals/PermissionsModal.vue +30 -13
  54. package/src/modals/PreviewModal.vue +9 -9
  55. package/src/styles/base.css +520 -26
  56. package/src/styles/variables.css +1 -1
  57. package/src/types/ExplorerConfig.ts +18 -2
  58. package/src/types/FileNode.ts +1 -1
  59. package/src/viewers/CsvViewer.vue +4 -4
  60. package/src/viewers/DrawioViewer.vue +2 -2
  61. package/dist/CsvViewer-DxujFEM3.js.map +0 -1
  62. package/dist/DrawioViewer-DCvWnX2t.js.map +0 -1
@@ -4,7 +4,7 @@
4
4
  * Backend POST /files/{copy,move,delete} returns `{op}` immediately
5
5
  * (status='pending') and the actual S3 work runs in a worker. The
6
6
  * client polls `/files/ops` every 2s while there's anything still
7
- * pending or running so the user gets live feedback ("3/5 kopyalandı").
7
+ * pending or running so the user gets live feedback ("3/5 copied").
8
8
  *
9
9
  * Polling is reference-counted: callers `register(op)` after starting a
10
10
  * new op; an interval ticks while the registered set has any non-
@@ -1,5 +1,5 @@
1
1
  /**
2
- * useTabs — wiring:d1 sekmeler (çalışma alanı tab şeridi).
2
+ * useTabs — wiring:d1 tabs (the workspace tab strip).
3
3
  *
4
4
  * A LAYER ABOVE FileExplorer's location state, never a replacement for
5
5
  * it: each tab is a location snapshot `{ id, path, viewMode, split }`.
@@ -85,10 +85,17 @@ export interface UploadOptions {
85
85
  onDone?: (job: UploadJob, result: UploadResult) => void;
86
86
  onError?: (job: UploadJob, err: Error) => void;
87
87
  /**
88
- * Wait for the backend transfer before resolving. Default false: the node is
89
- * listed the moment the commit is accepted, which is the whole point of
90
- * staging. `true` is for a caller that must not report success until the
91
- * bytes are on the driver.
88
+ * Wait for the backend transfer before resolving. Defaults to TRUE, because
89
+ * "the server has the bytes" and "the storage has the bytes" are different
90
+ * claims and only the second one is an upload.
91
+ *
92
+ * ⚠ It used to default to false and no caller ever set it, so the job went
93
+ * straight to `done` on the 202 — a transfer that failed afterwards in the
94
+ * ops worker left the user looking at a finished upload for a file that was
95
+ * never stored (issue #16: every file over the chunk threshold, on every S3
96
+ * backend). The `transferring` phase is short for a healthy storage and is
97
+ * the only place a failure can still be shown, so it is the default; pass
98
+ * false to opt out where the wait is genuinely unwanted.
92
99
  */
93
100
  waitForTransfer?: boolean;
94
101
  }
@@ -389,7 +396,7 @@ export function useUploadChunked(
389
396
  job.opId = result?.op_id;
390
397
  job.nodeId = result?.node_id;
391
398
 
392
- if (opts.waitForTransfer && result?.op_id && api.endpoints.opsShow) {
399
+ if (opts.waitForTransfer !== false && result?.op_id && api.endpoints.opsShow) {
393
400
  job.status = 'transferring';
394
401
  report();
395
402
  await waitForOp(result.op_id);
@@ -484,17 +491,31 @@ export function useUploadChunked(
484
491
  if (!tmpl) return;
485
492
  const url = tmpl.replace('{id}', String(opId));
486
493
  let delay = 200;
494
+ // ⚠ A tray read that keeps failing must not park the upload in
495
+ // `transferring` for ever. One hiccup is not a verdict, but a row we
496
+ // cannot read at all (purged, or the endpoint is gone) is unknowable, so
497
+ // after a bounded run of failures the wait ends and the job settles the
498
+ // way it did before the wait existed — optimistic, but not hung.
499
+ let misses = 0;
487
500
  for (;;) {
488
501
  if (cancelled) throw new DOMException('Aborted by user', 'AbortError');
489
502
  try {
490
503
  const op = await api.jsonFetch<{ status?: string; error?: string }>(url);
491
504
  if (op?.status === 'ok') return;
492
505
  if (op?.status === 'failed' || op?.status === 'partial') {
493
- throw new Error(op.error || 'transfer failed');
506
+ throw new TransferFailedError(op.error || 'transfer failed');
494
507
  }
508
+ misses = 0;
495
509
  } catch (err) {
496
- if ((err as Error).message === 'transfer failed' || (err as Error).name === 'AbortError') throw err;
510
+ // The verdict is told apart from the hiccup by TYPE, not by
511
+ // message. It used to be `message === 'transfer failed'`, which only
512
+ // matched when the server sent NO error text: the moment it sent a
513
+ // real one — which is every interesting failure — the throw above was
514
+ // swallowed here and the poll span for ever. Harmless while nothing
515
+ // set waitForTransfer; a hang the moment anything did.
516
+ if (err instanceof TransferFailedError || (err as Error).name === 'AbortError') throw err;
497
517
  /* a hiccup reading the tray is not a failed transfer */
518
+ if (++misses >= MAX_OP_POLL_MISSES) return;
498
519
  }
499
520
  await sleep(delay);
500
521
  delay = Math.min(delay * 2, 2000);
@@ -514,6 +535,17 @@ export function useUploadChunked(
514
535
  };
515
536
  }
516
537
 
538
+ /** How many consecutive unreadable op polls end the transfer wait (~7s). */
539
+ const MAX_OP_POLL_MISSES = 6;
540
+
541
+ /** The backend said the transfer failed — as opposed to "I could not ask". */
542
+ class TransferFailedError extends Error {
543
+ constructor(message: string) {
544
+ super(message);
545
+ this.name = 'TransferFailedError';
546
+ }
547
+ }
548
+
517
549
  function sleep(ms: number): Promise<void> {
518
550
  return new Promise((r) => setTimeout(r, ms));
519
551
  }
package/src/index.ts CHANGED
@@ -98,7 +98,7 @@ export {
98
98
  } from './composables/useMonacoLoader';
99
99
 
100
100
  // ——— Locale catalogue (consumers may merge their own keys) ———
101
- export { messages, tr, en } from './locales';
101
+ export { messages, tr, en, resolveLocale, detectLocale } from './locales';
102
102
 
103
103
  /* wiring:c1 — theme registry + gallery (hosts can list/apply themes programmatically) */
104
104
  export {
@@ -150,7 +150,7 @@ export type { TabState, TabSplit, TabsApi } from './composables/useTabs';
150
150
  export { default as TabBar } from './components/TabBar.vue';
151
151
  export { default as SecondaryPane } from './components/SecondaryPane.vue';
152
152
  /* /wiring:d1 */
153
- /* wiring:e2 — uçtan uca şifreli klasörler (WebCrypto; docs/E2E-ENCRYPTION.md) */
153
+ /* wiring:e2 — end-to-end encrypted folders (WebCrypto; docs/E2E-ENCRYPTION.md) */
154
154
  export {
155
155
  E2E_MARKER_NAME,
156
156
  E2E_MAGIC,
@@ -173,6 +173,10 @@ export {
173
173
  unlockWithEscrowKey,
174
174
  markerHasRecovery,
175
175
  markerHasEscrow,
176
+ escrowAvailability,
177
+ escrowOfferState,
178
+ addEscrowSlot,
179
+ declineEscrowSlot,
176
180
  generateRecoveryKey,
177
181
  formatRecoveryKey,
178
182
  parseRecoveryKey,
@@ -190,6 +194,8 @@ export type {
190
194
  E2eFmkMode,
191
195
  E2eRecoverySlot,
192
196
  E2eEscrowSlot,
197
+ EscrowAvailability,
198
+ EscrowOfferState,
193
199
  CreateFolderOptions,
194
200
  CreatedFolder,
195
201
  } from './lib/e2ecrypto';
@@ -40,10 +40,19 @@
40
40
  * - No key, password or recovery key is ever stored, logged or sent to a
41
41
  * server. The FMK lives in an in-memory key ring and dies with the tab.
42
42
  * - `deriveKek` imports non-extractable. Raw KEK bytes are produced ONLY
43
- * by `deriveKekBits`, only while upgrading a v1 marker, and only long
44
- * enough to wrap them into the new slots.
43
+ * by `deriveKekBits`, only for a marker whose FMK *is* the KEK
44
+ * (`upgradeMarkerV1`, and `addEscrowSlot` on a folder it produced), and
45
+ * only long enough to wrap them into a slot.
45
46
  * - A folder created while escrow was off carries no escrow slot, so the
46
- * escrow key cannot open it. That is arithmetic, not policy.
47
+ * escrow key cannot open it, and nothing the OPERATOR does changes
48
+ * that — not enabling escrow, not adopting it, not any admin action or
49
+ * future version. That is arithmetic, not policy: adding a slot needs
50
+ * the folder master key, and the server has never held a credential
51
+ * that produces one.
52
+ * - The folder's OWNER can, from inside, with the password:
53
+ * `addEscrowSlot`. That is the only door, it opens from one side only,
54
+ * and it is the reason `escrowAvailability` says "not as things stand"
55
+ * rather than "never".
47
56
  *
48
57
  * File layout ('filexe2e' magic, fixed 97-byte header) — UNCHANGED in v2:
49
58
  * [0..8) magic "filexe2e"
@@ -114,6 +123,23 @@ export interface E2eMarker {
114
123
  rk?: E2eRecoverySlot;
115
124
  /** v2 only, optional: the operator escrow slot. */
116
125
  esc?: E2eEscrowSlot;
126
+ /**
127
+ * v2 only, optional: an ISO timestamp recording that this folder's owner
128
+ * was OFFERED an escrow slot and said no.
129
+ *
130
+ * It lives in the marker rather than in browser storage because the unit
131
+ * of the decision is the FOLDER, not the device: the same person opening
132
+ * the folder from their phone must not be asked again, and a decision
133
+ * that vanished when someone cleared their site data would be no decision
134
+ * at all. It travels with the folder through a move, a backup and a
135
+ * restore, for the same reason the key slots do.
136
+ *
137
+ * It holds no key material and hides nothing from the operator — it is a
138
+ * record of an answer, and its only effect is that filex stops asking.
139
+ * `addEscrowSlot` clears it, so a decline is reversible by the one person
140
+ * who can reverse it.
141
+ */
142
+ esc_declined?: string;
117
143
  }
118
144
 
119
145
  /** Thrown on wrong password / corrupted ciphertext (GCM tag mismatch). */
@@ -500,6 +526,126 @@ export async function upgradeMarkerV1(
500
526
  return { marker: next, fmk: kek, recoveryKey };
501
527
  }
502
528
 
529
+ /**
530
+ * Give an EXISTING v2 folder an escrow slot, in place, using the folder
531
+ * password its owner has just typed.
532
+ *
533
+ * ── Why this exists ─────────────────────────────────────────────────
534
+ *
535
+ * Escrow used to be all-or-nothing at install time, and then adoptable but
536
+ * never retroactive: on any installation that had been running for a while,
537
+ * escrow covered only folders nobody had created yet. On a real deployment
538
+ * the folders that matter already exist, so "new folders only" means escrow
539
+ * covers nothing anyone cares about.
540
+ *
541
+ * The server still cannot do this, and that has not changed: adding a slot
542
+ * needs the folder master key, which needs a credential the server has never
543
+ * held. What CAN do it is the browser, at the one moment the password is in
544
+ * memory — exactly where `upgradeMarkerV1` already lives. Same moment, same
545
+ * shape, different slot.
546
+ *
547
+ * ⚠⚠ Accepting hands the operator of this installation a second, permanent
548
+ * way into this folder. It is the folder's owner who decides, from inside,
549
+ * with the password; no configuration change and no admin action can do it
550
+ * for them. The caller MUST say that in those words before calling this —
551
+ * see `e2e.escrowoffer.*` in the locales.
552
+ *
553
+ * ⚠ v2 only. A v1 marker has no slots at all; the path for those is
554
+ * `upgradeMarkerV1`, which already seals an escrow slot when the
555
+ * installation has a key and already discloses it. Two doors into the same
556
+ * room would be two chances to get the disclosure wrong.
557
+ *
558
+ * ⚠ No file is re-encrypted, moved or rewritten. Only `.filex-e2e.json`
559
+ * changes, and only by gaining `esc` (and losing `esc_declined`).
560
+ */
561
+ export async function addEscrowSlot(
562
+ marker: E2eMarker,
563
+ password: string,
564
+ escrowPublicKey: string,
565
+ ): Promise<E2eMarker> {
566
+ if (marker.v !== 2) throw new Error('e2e: not a v2 marker');
567
+ if (marker.esc) throw new Error('e2e: this folder already has an escrow slot');
568
+ if (!escrowPublicKey) throw new Error('e2e: no escrow public key');
569
+
570
+ const salt = b64ToBytes(marker.salt);
571
+ const kek = await deriveKek(password, salt, marker.iter);
572
+ // Prove the password before touching anything, exactly as the v1 upgrade
573
+ // does. A wrong password here must not produce a marker at all — half a
574
+ // marker is a folder nobody can open.
575
+ const proof = await gcmOpen(kek, marker.verify);
576
+ if (!proof || new TextDecoder().decode(proof) !== VERIFY_PLAINTEXT) {
577
+ throw new E2eDecryptError('e2e: wrong password');
578
+ }
579
+
580
+ // The raw FMK, by mode. `wrapped` keeps a random FMK in `fmk_pw`, so the
581
+ // bytes come back from a GCM open and no extractable KEK is ever derived.
582
+ // `kek` (a v1 folder upgraded in place) defines the FMK AS the password
583
+ // key, so those very bytes are what the slot has to wrap — the one case
584
+ // that needs `deriveKekBits`, for the same reason `upgradeMarkerV1` does.
585
+ let rawFmk: Uint8Array;
586
+ if (marker.fmk === 'wrapped') {
587
+ const opened = marker.fmk_pw ? await gcmOpen(kek, marker.fmk_pw) : null;
588
+ if (!opened || opened.length !== FMK_LEN) {
589
+ throw new E2eDecryptError('e2e: could not unwrap the folder master key');
590
+ }
591
+ rawFmk = opened;
592
+ } else {
593
+ rawFmk = await deriveKekBits(password, salt, marker.iter);
594
+ }
595
+
596
+ const esc = await sealEscrowSlot(rawFmk, escrowPublicKey);
597
+ rawFmk.fill(0);
598
+
599
+ const next: E2eMarker = { ...marker, esc };
600
+ // A decline that is now moot. Leaving it would make the record say two
601
+ // contradictory things about the same folder.
602
+ delete next.esc_declined;
603
+ return next;
604
+ }
605
+
606
+ /**
607
+ * Record that this folder's owner was offered an escrow slot and declined.
608
+ *
609
+ * A refusal is a decision, not a delay: without this the offer would come
610
+ * back on every single unlock, which is how people learn to click past
611
+ * security dialogs without reading them. Nothing about the folder's keys
612
+ * changes — the only effect is that filex stops asking.
613
+ *
614
+ * Reversible by `addEscrowSlot`, which is the way back for somebody who
615
+ * says no today and changes their mind next month.
616
+ */
617
+ export function declineEscrowSlot(marker: E2eMarker, when: string): E2eMarker {
618
+ return { ...marker, esc_declined: when };
619
+ }
620
+
621
+ /**
622
+ * Whether this folder's owner should be offered an escrow slot, and whether
623
+ * they have already answered.
624
+ *
625
+ * 'n/a' nothing to offer: the installation has no escrow key, the
626
+ * folder already has a slot, or the marker is v1 (whose path
627
+ * is `upgradeMarkerV1`).
628
+ * 'offer' the offer applies and no answer has been recorded.
629
+ * 'declined' the offer applies and the owner said no. Do not ask again;
630
+ * leave a way back.
631
+ *
632
+ * ⚠ This deliberately does NOT look at whether the folder is unlocked. That
633
+ * is the caller's business, and it matters: the offer may only be shown
634
+ * after an unlock actually succeeded, because accepting needs the password
635
+ * and because asking someone who cannot open the folder to give away a key
636
+ * to it is asking the wrong person.
637
+ */
638
+ export type EscrowOfferState = 'n/a' | 'offer' | 'declined';
639
+
640
+ export function escrowOfferState(
641
+ m: E2eMarker | null,
642
+ installationKid: string | null | undefined,
643
+ ): EscrowOfferState {
644
+ if (!installationKid) return 'n/a';
645
+ if (!m || m.v !== 2 || m.esc) return 'n/a';
646
+ return m.esc_declined ? 'declined' : 'offer';
647
+ }
648
+
503
649
  /** Parse marker JSON text; returns null when the shape is not a marker we read. */
504
650
  export function parseMarker(text: string): E2eMarker | null {
505
651
  try {
@@ -527,6 +673,48 @@ export function markerHasEscrow(m: E2eMarker | null): boolean {
527
673
  return !!m && m.v === 2 && !!m.esc;
528
674
  }
529
675
 
676
+ /**
677
+ * Why the escrow door is, or is not, on offer for this folder.
678
+ *
679
+ * 'off' this installation has no escrow key at all.
680
+ * 'available' the folder is sealed to THIS installation's escrow key.
681
+ * 'predates' the installation has an escrow key, and this folder has no
682
+ * escrow slot: it was created before escrow existed here.
683
+ * 'other-key' the folder carries an escrow slot sealed to a DIFFERENT
684
+ * key id — it came from another installation, via a restore
685
+ * or a copied data directory.
686
+ *
687
+ * ⚠ 'predates' exists because escrow can be ADOPTED by an installation
688
+ * that already has folders (FILEX_INSTALLATION_E2E_ESCROW_ADOPT), and
689
+ * adoption is not retroactive: the folder's master key was wrapped to its
690
+ * recovery paths when the folder was created. Before this distinction
691
+ * existed the dialog simply showed no Escrow tab, which is true but says
692
+ * nothing — an admin who knows escrow is on reads a missing tab as a bug,
693
+ * tries the key anyway, and learns the real answer from a failure. The UI
694
+ * has to say it instead.
695
+ *
696
+ * ⚠⚠ 'predates' means "not as things stand", NOT "never". The folder's
697
+ * owner can add a slot from inside with the password (`addEscrowSlot`,
698
+ * offered at unlock). Any wording built on this state has to leave that
699
+ * door visible, or it tells an operator their escrow key can never reach a
700
+ * folder whose owner could hand it over this afternoon.
701
+ *
702
+ * ⚠ 'other-key' was a quieter lie: the dialog labelled the escrow field
703
+ * with the INSTALLATION's key id whatever the folder's slot said, so a
704
+ * folder restored from another install looked openable by the key the
705
+ * operator has, and was not.
706
+ */
707
+ export type EscrowAvailability = 'off' | 'available' | 'predates' | 'other-key';
708
+
709
+ export function escrowAvailability(
710
+ m: E2eMarker | null,
711
+ installationKid: string | null | undefined,
712
+ ): EscrowAvailability {
713
+ if (!installationKid) return 'off';
714
+ if (!markerHasEscrow(m)) return 'predates';
715
+ return m!.esc!.kid === installationKid ? 'available' : 'other-key';
716
+ }
717
+
530
718
  /**
531
719
  * Check `password` against a folder marker. Resolves to the derived KEK on
532
720
  * success, or `null` on a wrong password (GCM tag mismatch on the verify
@@ -0,0 +1,143 @@
1
+ /**
2
+ * surucu:d1 — the filter row's model, kept out of the components so the
3
+ * predicate has one definition and can be tested without a DOM.
4
+ *
5
+ * ⚠ EVERY filter here answers from a field the listing row ALREADY carries
6
+ * (`type`, `extension`, `mime_type`, `size`, `last_modified`). That is not a
7
+ * shortcut, it is the constraint: `GET /api/files/manager?action=index` reads
8
+ * no `mime` / `min_size` / `modified_after` / `owner` parameter — it returns
9
+ * the whole directory and ignores anything else you send it (handlers/manager.go
10
+ * `List`, the complete parameter list is `action`, `path`, `filter`, `storage`,
11
+ * `parent`, `cache`). A control wired to a parameter the server does not read
12
+ * looks like it works and quietly changes nothing, which is worse than not
13
+ * shipping it.
14
+ *
15
+ * Because the endpoint has no `limit`/`offset` either, the listing in hand IS
16
+ * the folder — so filtering it client-side is complete for the folder, not a
17
+ * filter over "the first page". That is exactly why these three are honest and
18
+ * a People/owner filter is not: `nodes.owner_id` exists for quota accounting,
19
+ * is nil for anything a sync discovered, and is serialized by nothing
20
+ * (handlers/shared.go: "There is no per-node owner").
21
+ */
22
+ import type { FileNode } from '../types/FileNode';
23
+ import { iconFamilyFor, type IconFamily } from './fileIcons';
24
+
25
+ export type TypeFilter =
26
+ | 'any'
27
+ | 'folder'
28
+ | 'document'
29
+ | 'spreadsheet'
30
+ | 'presentation'
31
+ | 'pdf'
32
+ | 'image'
33
+ | 'video'
34
+ | 'audio'
35
+ | 'archive'
36
+ | 'code';
37
+
38
+ export type ModifiedFilter = 'any' | 'today' | '7d' | '30d' | 'year';
39
+
40
+ export type SizeFilter = 'any' | 'lt1' | '1to10' | '10to100' | 'gt100';
41
+
42
+ export interface DriveFilters {
43
+ type: TypeFilter;
44
+ modified: ModifiedFilter;
45
+ size: SizeFilter;
46
+ }
47
+
48
+ export const EMPTY_FILTERS: DriveFilters = { type: 'any', modified: 'any', size: 'any' };
49
+
50
+ export function filtersActive(f: DriveFilters): boolean {
51
+ return f.type !== 'any' || f.modified !== 'any' || f.size !== 'any';
52
+ }
53
+
54
+ export function activeFilterCount(f: DriveFilters): number {
55
+ return (f.type !== 'any' ? 1 : 0) + (f.modified !== 'any' ? 1 : 0) + (f.size !== 'any' ? 1 : 0);
56
+ }
57
+
58
+ /** Families a type choice accepts. `iconFamilyFor` is the taxonomy the icons
59
+ * already use, so a row's filter group and its glyph can never disagree. */
60
+ const TYPE_FAMILIES: Record<Exclude<TypeFilter, 'any'>, IconFamily[]> = {
61
+ folder: ['folder'],
62
+ document: ['doc', 'text'],
63
+ spreadsheet: ['sheet'],
64
+ presentation: ['slides'],
65
+ pdf: ['pdf'],
66
+ image: ['image'],
67
+ video: ['video'],
68
+ audio: ['audio'],
69
+ archive: ['archive'],
70
+ code: ['code'],
71
+ };
72
+
73
+ /** MIME fallback — a file with no extension still has a `mime_type` from the
74
+ * backend sniffer, and "IMG_0042" with no suffix is a real thing people have. */
75
+ const MIME_PREFIXES: Partial<Record<Exclude<TypeFilter, 'any'>, string[]>> = {
76
+ image: ['image/'],
77
+ video: ['video/'],
78
+ audio: ['audio/'],
79
+ pdf: ['application/pdf'],
80
+ document: ['text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessing'],
81
+ spreadsheet: ['text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheet'],
82
+ presentation: ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentation'],
83
+ archive: ['application/zip', 'application/x-tar', 'application/gzip', 'application/x-7z'],
84
+ };
85
+
86
+ function matchesType(n: FileNode, t: TypeFilter): boolean {
87
+ if (t === 'any') return true;
88
+ if (t === 'folder') return n.type === 'dir';
89
+ if (n.type === 'dir') return false;
90
+ if (TYPE_FAMILIES[t].includes(iconFamilyFor(n))) return true;
91
+ const mime = (n.mime_type || '').toLowerCase();
92
+ return !!mime && (MIME_PREFIXES[t] ?? []).some((p) => mime.startsWith(p));
93
+ }
94
+
95
+ /** `now` is a parameter so a test can pin the clock instead of sleeping. */
96
+ function matchesModified(n: FileNode, f: ModifiedFilter, now: number): boolean {
97
+ if (f === 'any') return true;
98
+ const ms = typeof n.last_modified === 'number' ? n.last_modified : 0;
99
+ // No timestamp = no answer. Dropping the row would hide files whose driver
100
+ // gave us nothing; keeping it would put them in "Today". Hiding is the
101
+ // honest one: the row does not satisfy "modified today", it is unknown.
102
+ if (!ms) return false;
103
+ if (f === 'year') {
104
+ return new Date(ms).getFullYear() === new Date(now).getFullYear();
105
+ }
106
+ if (f === 'today') {
107
+ const start = new Date(now);
108
+ start.setHours(0, 0, 0, 0);
109
+ return ms >= start.getTime();
110
+ }
111
+ const days = f === '7d' ? 7 : 30;
112
+ return ms >= now - days * 86_400_000;
113
+ }
114
+
115
+ const MB = 1024 * 1024;
116
+
117
+ function matchesSize(n: FileNode, f: SizeFilter): boolean {
118
+ if (f === 'any') return true;
119
+ // ⚠ Folders drop out of every size choice rather than passing through. A
120
+ // directory row's `size` is 0 from the projector, so "under 1 MB" would
121
+ // otherwise list every folder in the drive — an answer that looks like a
122
+ // measurement and is not one.
123
+ if (n.type === 'dir') return false;
124
+ const s = typeof n.size === 'number' ? n.size : 0;
125
+ if (f === 'lt1') return s < MB;
126
+ if (f === '1to10') return s >= MB && s < 10 * MB;
127
+ if (f === '10to100') return s >= 10 * MB && s < 100 * MB;
128
+ return s >= 100 * MB;
129
+ }
130
+
131
+ export function applyFilters(
132
+ files: FileNode[],
133
+ f: DriveFilters,
134
+ now: number = Date.now(),
135
+ ): FileNode[] {
136
+ // No active filter → the SAME array reference, so an unfiltered explorer
137
+ // renders exactly what it rendered before this file existed.
138
+ if (!filtersActive(f)) return files;
139
+ return files.filter(
140
+ (n) =>
141
+ matchesType(n, f.type) && matchesModified(n, f.modified, now) && matchesSize(n, f.size),
142
+ );
143
+ }
@@ -120,14 +120,61 @@ export function makeTrashRow(adapter: string): FileNode {
120
120
  * Inject the virtual `.trash` row at the front of a root listing when
121
121
  * enabled. Returns true if a row was added (so the caller can hydrate
122
122
  * it). Mutates `files` in place. Single source for both panes.
123
+ *
124
+ * ⚠ `isSearchResult` is not optional politeness — it is the one thing
125
+ * `dirname` cannot tell you. A search answers with the SCOPE it searched, so
126
+ * `?action=search&path=main://&filter=brief` comes back with
127
+ * `dirname: "main://"`, identical to the folder listing of the same path
128
+ * (measured). Without this flag the sentinel is unshifted into the search
129
+ * results, and a search for "brief" answers with `brief.md` and a folder
130
+ * called Trash that is not a folder, is not a hit, and cannot be searched for.
131
+ * Same family as the `.trash` / `.shared` sentinel bugs fixed in v0.31.0: a
132
+ * virtual row rendered somewhere it has no meaning.
133
+ *
134
+ * Both flags default to `false`, i.e. to the historical behaviour: a caller
135
+ * that never searches and has no panel — which is what an embed with
136
+ * `sideNav: false` is — keeps the row it has always had by saying nothing.
123
137
  */
138
+ export interface TrashRowContext {
139
+ /**
140
+ * This listing is the answer to a SEARCH, not the contents of a folder.
141
+ *
142
+ * ⚠ The one thing `dirname` cannot tell you: a search answers with the scope
143
+ * it searched, so `?action=search&path=main://&filter=notes` comes back
144
+ * carrying `dirname: "main://"`, byte-identical to that folder's listing
145
+ * (measured). Without this the sentinel lands among the hits.
146
+ */
147
+ isSearchResult?: boolean;
148
+ /**
149
+ * The navigation panel is already offering Trash as a destination.
150
+ *
151
+ * ⚠ This row only ever existed because a listing had no other way into the
152
+ * bin. Once the panel carries a Trash entry that reason is gone and the row
153
+ * is a second door to the same place — drawn as a 0-byte folder that is not
154
+ * a folder, sitting among real ones, while the panel's own Trash entry is
155
+ * three inches to its left. Owner's decision, this release: do not offer the
156
+ * same door twice.
157
+ *
158
+ * ⚠ It is a question about the PANEL, not about the profile. The duplication
159
+ * is exactly as wrong in `standard` with the panel on as it is in `drive`;
160
+ * keying it to `uiProfile` would be a rule about the wrong thing.
161
+ */
162
+ navOffersTrash?: boolean;
163
+ }
164
+
124
165
  export function injectTrashRow(
125
166
  files: FileNode[],
126
167
  adapter: string,
127
168
  dirname: string,
128
169
  trashVisible: boolean,
170
+ ctx: TrashRowContext = {},
129
171
  ): boolean {
172
+ // The host's own switch, and still the outer gate: `trashVisible: false`
173
+ // means no Trash anywhere — no row here, and no entry in the panel either,
174
+ // so this must be answered before anything about the panel is considered.
130
175
  if (!trashVisible) return false;
176
+ if (ctx.navOffersTrash) return false;
177
+ if (ctx.isSearchResult) return false;
131
178
  if (isTrashListing(dirname)) return false;
132
179
  if (!isStorageRootDir(dirname)) return false;
133
180
  files.unshift(makeTrashRow(adapter));
package/src/lib/themes.ts CHANGED
@@ -105,7 +105,7 @@ export const THEMES: ThemeDef[] = [
105
105
  },
106
106
  },
107
107
  {
108
- // Gece Mavisi — deep indigo/navy.
108
+ // Night Blue — deep indigo/navy.
109
109
  id: 'night',
110
110
  nameKey: 'theme.name.night',
111
111
  light: {
@@ -140,7 +140,7 @@ export const THEMES: ThemeDef[] = [
140
140
  },
141
141
  },
142
142
  {
143
- // Orman — calm greens.
143
+ // Forest — calm greens.
144
144
  id: 'forest',
145
145
  nameKey: 'theme.name.forest',
146
146
  light: {
@@ -175,7 +175,7 @@ export const THEMES: ThemeDef[] = [
175
175
  },
176
176
  },
177
177
  {
178
- // Kehribar — warm amber/bronze.
178
+ // Amber — warm amber/bronze.
179
179
  id: 'amber',
180
180
  nameKey: 'theme.name.amber',
181
181
  light: {
@@ -210,7 +210,7 @@ export const THEMES: ThemeDef[] = [
210
210
  },
211
211
  },
212
212
  {
213
- // Leylak — soft purple.
213
+ // Lilac — soft purple.
214
214
  id: 'lilac',
215
215
  nameKey: 'theme.name.lilac',
216
216
  light: {
@@ -245,7 +245,7 @@ export const THEMES: ThemeDef[] = [
245
245
  },
246
246
  },
247
247
  {
248
- // Yüksek Kontrast — black/white with strong borders, saturated accents.
248
+ // High Contrast — black/white with strong borders, saturated accents.
249
249
  id: 'contrast',
250
250
  nameKey: 'theme.name.contrast',
251
251
  light: {
@@ -284,7 +284,7 @@ export const THEMES: ThemeDef[] = [
284
284
  },
285
285
  },
286
286
  {
287
- // Yumuşak Gri — desaturated, quiet neutral.
287
+ // Soft Gray — desaturated, quiet neutral.
288
288
  id: 'gray',
289
289
  nameKey: 'theme.name.gray',
290
290
  light: {
@@ -319,7 +319,7 @@ export const THEMES: ThemeDef[] = [
319
319
  },
320
320
  },
321
321
  {
322
- // Terminal Yeşili — phosphor green, monospace face for the full CRT vibe.
322
+ // Terminal Green — phosphor green, monospace face for the full CRT vibe.
323
323
  id: 'terminal',
324
324
  nameKey: 'theme.name.terminal',
325
325
  light: {
@@ -3,14 +3,14 @@
3
3
  *
4
4
  * Three gestures land on the same wire call — drag a row onto a folder, paste
5
5
  * after Ctrl+X, paste after Ctrl+C — and they do NOT mean the same thing once
6
- * the two ends live in different depolar:
6
+ * the two ends live in different storages:
7
7
  *
8
8
  * • Ctrl+C → paste is a copy, wherever it lands.
9
- * • Ctrl+X → paste is a move, wherever it lands. Across depolar the server
9
+ * • Ctrl+X → paste is a move, wherever it lands. Across storages the server
10
10
  * streams the bytes over and then deletes the original; before v0.27.0 the
11
11
  * explorer quietly downgraded this to a copy and the user was left with the
12
12
  * file in both places, believing they had moved it.
13
- * • Dragging is a move inside one depo and a COPY across two — the rule
13
+ * • Dragging is a move inside one storage and a COPY across two — the rule
14
14
  * Explorer and Finder have taught everyone: a drag between drives copies.
15
15
  *
16
16
  * Kept as a pure function so both the pane path and the clipboard path ask the
@@ -23,13 +23,13 @@ export function wireAdapterOf(p: string): string {
23
23
  return i === -1 ? '' : p.slice(0, i);
24
24
  }
25
25
 
26
- /** What the caller asked for. `auto` is a drag: let the depolar decide. */
26
+ /** What the caller asked for. `auto` is a drag: let the storages decide. */
27
27
  export type TransferIntent = 'auto' | 'copy' | 'move';
28
28
 
29
29
  export interface TransferPlan {
30
30
  /** What to actually ask the server for. */
31
31
  kind: 'copy' | 'move';
32
- /** True when at least one source lives in another depo than the target. */
32
+ /** True when at least one source lives in another storage than the target. */
33
33
  cross: boolean;
34
34
  }
35
35
 
@@ -40,7 +40,7 @@ export function resolveTransfer(
40
40
  ): TransferPlan {
41
41
  const target = wireAdapterOf(targetWire);
42
42
  // A source with no prefix is a legacy embedder's bare path: it can only mean
43
- // "the same depo I am looking at", so it never counts as crossing.
43
+ // "the same storage I am looking at", so it never counts as crossing.
44
44
  const cross = sources.some((p) => {
45
45
  const a = wireAdapterOf(p);
46
46
  return a !== '' && target !== '' && a !== target;