@brftech/filex-core 0.31.0 → 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.
@@ -46,6 +46,14 @@ import RecentlyOpened from './components/RecentlyOpened.vue';
46
46
  import Breadcrumb from './components/Breadcrumb.vue';
47
47
  import ListView from './components/ListView.vue';
48
48
  import GridView from './components/GridView.vue';
49
+ import FilterBar from './components/FilterBar.vue' /* surucu:d1 */;
50
+ import ViewSwitcher from './components/ViewSwitcher.vue' /* surucu:d1 */;
51
+ import {
52
+ EMPTY_FILTERS,
53
+ applyFilters,
54
+ filtersActive,
55
+ type DriveFilters,
56
+ } from './lib/fileFilters' /* surucu:d1 */;
49
57
  import GalleryView from './components/GalleryView.vue'; /* wiring:d2 */
50
58
  import ContextMenu, { type ContextAction } from './components/ContextMenu.vue';
51
59
  import UploadProgress from './components/UploadProgress.vue';
@@ -92,13 +100,16 @@ import {
92
100
  createKeyRing,
93
101
  createEncryptedFolder,
94
102
  upgradeMarkerV1,
103
+ addEscrowSlot,
104
+ declineEscrowSlot,
105
+ escrowOfferState,
95
106
  parseMarker,
96
107
  unlockWithPassword,
97
108
  unlockWithRecoveryKey,
98
109
  unlockWithEscrowKey,
99
110
  importEscrowPrivateKey,
100
111
  markerHasRecovery,
101
- markerHasEscrow,
112
+ escrowAvailability,
102
113
  bytesToB64,
103
114
  b64ToBytes,
104
115
  encryptFile,
@@ -496,8 +507,16 @@ function onStarChange(n: FileNode, value: boolean) {
496
507
  * this calls it. A menu cannot render a component, but it must not grow its
497
508
  * own fetch either — that is the second path that drifts.
498
509
  */
499
- /** Which of `targets` can carry a star: files the server knows by id. */
510
+ /** Which of `targets` can carry a star: files the server knows by id.
511
+ *
512
+ * Empty when the identity surfaces are suppressed, which is the one
513
+ * chokepoint the context menu, the toolbar and the keyboard action all go
514
+ * through — so the affordance disappears everywhere at once rather than in
515
+ * the two places somebody remembered. Offering to star a file while the
516
+ * Starred view is hidden would write into a list the person cannot open,
517
+ * and under a shared app token that list belongs to everyone at once. */
500
518
  function starableNodes(targets: FileNode[]): FileNode[] {
519
+ if (!identitySurfaces.value) return [];
501
520
  return targets.filter((n) => typeof n.id === 'number' && n.type === 'file');
502
521
  }
503
522
 
@@ -700,7 +719,17 @@ function closeInspector() {
700
719
  * the "one behaviour on one surface" split this shared package exists to
701
720
  * prevent. */
702
721
  const uiProfile = computed(() => props.config.uiProfile ?? 'standard');
703
- const simpleUi = computed(() => uiProfile.value === 'simple');
722
+ /**
723
+ * ⚠ `drive` answers TRUE here. It is a superset of `simple`, so every question
724
+ * `simple` already answers ("one pane?", "no tab strip?", "list and grid
725
+ * only?") must keep the same answer under it — asking `=== 'simple'` in those
726
+ * places is how the drive profile would silently grow a split pane the day
727
+ * somebody adds a fourth condition. `driveShell` is only for what `drive` adds
728
+ * on TOP.
729
+ */
730
+ const simpleUi = computed(() => uiProfile.value === 'simple' || uiProfile.value === 'drive');
731
+ /* === surucu:d1 — the Drive shell (GitHub #14, the reporter's mockups) ===== */
732
+ const driveShell = computed(() => uiProfile.value === 'drive');
704
733
 
705
734
  const SIDENAV_LS_KEY = 'filex.sidenav';
706
735
  const sideNavExpanded = ref<boolean>(
@@ -740,6 +769,31 @@ const sideNavEnabled = computed(() => props.config.sideNav ?? !rootPathProp);
740
769
  const navVisible = computed(
741
770
  () => sideNavEnabled.value && (isNarrow.value ? navDrawerOpen.value : true),
742
771
  );
772
+ /**
773
+ * Is the navigation panel already offering Trash as a destination?
774
+ *
775
+ * ⚠ Keyed to `sideNavEnabled`, NOT to `navVisible`. Three cases decided here,
776
+ * and each one is a judgement about whether the person has a way to the bin
777
+ * that is not this row:
778
+ *
779
+ * - collapsed to the icon rail → OFFERING. The entry is still rendered, still
780
+ * one click, still labelled for a screen reader; only its text is gone.
781
+ * - the drawer under 560px, closed → OFFERING. The toolbar's panel toggle is
782
+ * on screen at every width, so the destination is one tap away. Keying this
783
+ * to `navVisible` instead would add and remove a row from the LISTING every
784
+ * time somebody opened or closed the drawer — the folder changing under
785
+ * them for a reason that has nothing to do with the folder.
786
+ * - no panel at all (`sideNav: false`, or the default under `rootPath`) → NOT
787
+ * offering, and the row stays. That is the case it was invented for: a
788
+ * listing with no other door to the bin.
789
+ *
790
+ * `trashVisible: false` is handled inside the helper and outranks all of it —
791
+ * it means no Trash anywhere, panel entry included.
792
+ */
793
+ const navOffersTrash = computed(
794
+ () => sideNavEnabled.value && props.config.trashVisible !== false,
795
+ );
796
+
743
797
  /** What the toolbar toggle reports as pressed. */
744
798
  const navToggleOn = computed(() =>
745
799
  isNarrow.value ? navDrawerOpen.value : sideNavExpanded.value,
@@ -840,6 +894,130 @@ watchEffect(() => {
840
894
  if (simpleUi.value && viewMode.value === 'gallery') viewMode.value = 'grid';
841
895
  });
842
896
 
897
+ /* === surucu:d1 — the filter row =========================================
898
+ * Three chips over the listing in hand: Type · Modified · Size.
899
+ *
900
+ * ⚠ CLIENT-SIDE, and that is the honest place for them, not a shortcut. The
901
+ * listing endpoint reads exactly six parameters (`action`, `path`, `filter`,
902
+ * `storage`, `parent`, `cache`) and has no `limit`/`offset` either — so the
903
+ * rows in hand ARE the folder, and filtering them here answers the whole
904
+ * question rather than "the first page of it". Wiring a chip to a `min_size`
905
+ * the server never reads would look identical and change nothing.
906
+ *
907
+ * ⚠ Reset on navigation. A filter that survives a folder change makes the next
908
+ * folder look empty, and the reason is off-screen the moment you scroll.
909
+ */
910
+ const driveFilters = ref<DriveFilters>({ ...EMPTY_FILTERS });
911
+ const filtersOn = computed(() => driveShell.value && filtersActive(driveFilters.value));
912
+ /** What the views render. Identical reference to `files` when nothing is set. */
913
+ const displayFiles = computed<FileNode[]>(() =>
914
+ driveShell.value ? applyFilters(files.value, driveFilters.value) : files.value,
915
+ );
916
+ function clearDriveFilters() {
917
+ setDriveFilters({ ...EMPTY_FILTERS });
918
+ }
919
+ function setDriveFilters(v: DriveFilters) {
920
+ driveFilters.value = v;
921
+ // A selection the filter just hid would still be what Delete acts on, with
922
+ // nothing on screen to say so.
923
+ selection.clear();
924
+ }
925
+ watch(
926
+ () => `${currentPath.value}|${navView.value}`,
927
+ () => {
928
+ if (filtersActive(driveFilters.value)) driveFilters.value = { ...EMPTY_FILTERS };
929
+ },
930
+ );
931
+
932
+ /**
933
+ * surucu:d1 — what the header field says it will search: the folder you are
934
+ * standing in, by name. At a storage root that is the storage; in a panel view
935
+ * ("Recent", a tag) it is that view's own name, because searching from there
936
+ * searches what is on screen.
937
+ */
938
+ const driveScopeLabel = computed(() => {
939
+ if (navView.value === 'tag') return navTag.value;
940
+ if (navView.value) return t(`sidenav.${navView.value}`);
941
+ const rel = currentPath.value.replace(/\/+$/, '');
942
+ const last = rel.split('/').filter(Boolean).pop();
943
+ return last || adapter.value || '';
944
+ });
945
+
946
+ /**
947
+ * surucu:d1 — the ⌘K escalation. The header field searches THIS folder (it
948
+ * sets `searchQuery`, which the loader answers with `action=search`); this
949
+ * hands the same words to the command palette, which is where "everywhere",
950
+ * the saved searches and the commands live. One box, one shortcut, and the
951
+ * hint printed on the box does what it says.
952
+ */
953
+ const paletteSeed = ref('');
954
+ function openPaletteWith(q: string) {
955
+ paletteSeed.value = q;
956
+ showPalette.value = true;
957
+ }
958
+
959
+ /**
960
+ * surucu:d1 — "Request files" from the New menu: the access modal on the
961
+ * CURRENT folder, opened on its file-drop tab. The modal already owns that
962
+ * surface; this only gives it a target, since the folder you are standing in
963
+ * is not a selected row and has no FileNode of its own.
964
+ */
965
+ const permInitialTab = ref<'perms' | 'share' | 'drop' | undefined>(undefined);
966
+ function openFileRequest() {
967
+ const path = qualify(currentPath.value);
968
+ if (!path) return;
969
+ const segs = currentPath.value.split('/').filter(Boolean);
970
+ permTarget.value = {
971
+ path,
972
+ basename: segs.length ? segs[segs.length - 1] : adapter.value,
973
+ type: 'dir',
974
+ };
975
+ permInitialTab.value = 'drop';
976
+ showPerm.value = true;
977
+ }
978
+
979
+ /** surucu:d1 — a link minted from the details panel; same event the share
980
+ * dialog emits, so a host listening for `share-created` hears both. */
981
+ function onInspectorShareCreated(payload: { path: string; url: string }) {
982
+ emit('share-created', { path: payload.path, url: payload.url, pin: null });
983
+ flashToast(t('inspector.copied'));
984
+ }
985
+
986
+ /* === surucu:d1 — the storage line under the navigation ==================
987
+ * Fetched once per mount, and ONLY in the drive shell: no other profile draws
988
+ * it, and an explorer that has drawn this panel for a year should not start
989
+ * making a request it has no use for. `quotaMe()` answers null for a server
990
+ * without the route or a caller without a person behind it, and null renders
991
+ * nothing at all.
992
+ */
993
+ const quotaSnapshot = ref<{ used: number; total: number; unlimited: boolean } | null>(null);
994
+ async function loadQuota() {
995
+ if (!driveShell.value || !identitySurfaces.value) {
996
+ quotaSnapshot.value = null;
997
+ return;
998
+ }
999
+ const q = await api.quotaMe();
1000
+ if (!q) {
1001
+ quotaSnapshot.value = null;
1002
+ return;
1003
+ }
1004
+ const unlimited = !!q.unlimited || q.quota_bytes <= 0;
1005
+ // ⚠ No ceiling AND nothing counted = nothing to say, so say nothing.
1006
+ //
1007
+ // Measured, not guessed: `used_bytes` is `SUM(nodes.size) WHERE owner_id = me`
1008
+ // and `nodes.owner_id` is set by the UPLOAD path only — it is nil for every
1009
+ // file a storage sync discovered (handlers/shared.go). On an install whose
1010
+ // drives were mounted rather than uploaded into, the honest figure is
1011
+ // therefore "0 B", and a line reading "0 B used" under a drive visibly full
1012
+ // of files reads as a broken widget, not as a fact about quota. With a quota
1013
+ // set the line still earns its place — the ceiling is real and uploads count
1014
+ // against it — so only the no-quota-no-usage case is dropped.
1015
+ quotaSnapshot.value =
1016
+ unlimited && q.used_bytes <= 0
1017
+ ? null
1018
+ : { used: q.used_bytes, total: q.quota_bytes, unlimited };
1019
+ }
1020
+
843
1021
  /**
844
1022
  * nodeRowToFileNode — the starred / recently-opened endpoints answer with raw
845
1023
  * node rows (relative `path`, numeric `storage_id`), not the listing shape.
@@ -1350,7 +1528,16 @@ async function load(path?: string) {
1350
1528
  files.value = filterListing(resp.files);
1351
1529
  // Inject virtual `.trash` entry at root only — shared helper so the
1352
1530
  // split-view secondary pane shows the exact same row (no row-offset).
1353
- if (injectTrashRow(files.value, resp.adapter, resp.dirname, props.config.trashVisible !== false)) {
1531
+ // …and NOT into a search result. The search response's `dirname` is the
1532
+ // scope it searched, so it is the storage root exactly as an `index` of
1533
+ // that folder would be; only this call site knows which of the two it just
1534
+ // asked for.
1535
+ if (
1536
+ injectTrashRow(files.value, resp.adapter, resp.dirname, props.config.trashVisible !== false, {
1537
+ isSearchResult: !!searchQuery.value,
1538
+ navOffersTrash: navOffersTrash.value,
1539
+ })
1540
+ ) {
1354
1541
  void hydrateTrashRowShared(files.value, resp.adapter, api);
1355
1542
  }
1356
1543
  // currentPath is the user-facing form: `s3-test/example` in
@@ -1655,6 +1842,9 @@ onMounted(async () => {
1655
1842
  module-level cache in lib/tags.ts means several explorers on one page
1656
1843
  still cost a single query. */
1657
1844
  void loadNavTags();
1845
+ /* surucu:d1 — the storage line. After the listing, like the tag list: it is
1846
+ a status, and the folder somebody asked for is the critical path. */
1847
+ void loadQuota();
1658
1848
  /* ⚠ The panel is not always on screen at mount. Below 560px it is a DRAWER
1659
1849
  that starts closed, so `navVisible` is false and the call above returns
1660
1850
  without asking for anything — measured at 390px: the drawer opened with
@@ -1806,6 +1996,11 @@ useKeyboardShortcuts(rootEl, {
1806
1996
  onGoUp: () => (paneIsActive.value ? splitPaneRef.value?.goUp() : goUp()) /* wiring:d1 pane-route */,
1807
1997
  /* cila:c wiring */
1808
1998
  onPathJump: () => {
1999
+ /* surucu:d1 — the shortcut from anywhere else opens a BLANK palette. The
2000
+ seed belongs to the drive header's field; leaving the last one in place
2001
+ would reopen the palette pre-filled with a query the user has since
2002
+ moved on from. */
2003
+ paletteSeed.value = '';
1809
2004
  showPalette.value = true;
1810
2005
  },
1811
2006
  onShowHelp: () => {
@@ -3906,11 +4101,42 @@ const e2eUpgradeOffer = ref(false);
3906
4101
  const e2eUpgradePw = ref('');
3907
4102
  const e2eUpgradeBusy = ref(false);
3908
4103
 
4104
+ /* wiring:e2 escrow-offer — offering an EXISTING folder an escrow slot.
4105
+ *
4106
+ * Adoption covers folders created after it, which on an installation that
4107
+ * has been running for a while is nobody's folders: the ones that matter
4108
+ * already exist. The server cannot reach them — adding a slot needs the
4109
+ * folder master key. Its owner can, from inside, with the password, and the
4110
+ * one moment that password exists in the browser is an unlock. So this
4111
+ * lives exactly where the v1 recovery offer lives, in the same strip, with
4112
+ * the same shape.
4113
+ *
4114
+ * mode distinguishes the two ways in:
4115
+ * 'unlock' right after a successful password unlock — we still hold the
4116
+ * password, so Accept needs no typing, and Not now RECORDS a
4117
+ * refusal so the question is not asked again.
4118
+ * 'manual' the way back, from the unlocked strip, for somebody who said
4119
+ * no earlier. The password is gone by then, so it is typed, and
4120
+ * Cancel records nothing — they already answered once. */
4121
+ const e2eEscrowOffer = ref(false);
4122
+ const e2eEscrowOfferMode = ref<'unlock' | 'manual'>('unlock');
4123
+ const e2eEscrowPw = ref('');
4124
+ const e2eEscrowBusy = ref(false);
4125
+ const e2eEscrowErr = ref('');
4126
+
3909
4127
  /** The installation's escrow public key, or null when escrow is off.
3910
4128
  * Published in /api/capabilities on purpose — see docs/E2E-ENCRYPTION.md. */
3911
4129
  const e2eEscrowPub = computed<string | null>(
3912
4130
  () => capabilitiesData.value?.e2e_escrow?.public_key || null,
3913
4131
  );
4132
+ /** Where the honest version of "what escrow can and cannot do" lives.
4133
+ * ⚠ Linked from the offer on purpose: the notice says what accepting means,
4134
+ * and the page says what the use-notification can and cannot promise — that
4135
+ * an operator holding the private key can decrypt offline with no request,
4136
+ * no notification and no audit row. Somebody being asked to hand over a key
4137
+ * is entitled to read that before answering, not after. */
4138
+ const e2eEscrowDocsUrl = 'https://docs.filex.sh/E2E-ENCRYPTION#what-escrow-can-and-cannot-do';
4139
+
3914
4140
  const e2eEscrowKid = computed<string | null>(
3915
4141
  () => capabilitiesData.value?.e2e_escrow?.kid || null,
3916
4142
  );
@@ -3933,7 +4159,7 @@ async function e2eUnlock() {
3933
4159
  try {
3934
4160
  let markerText = '';
3935
4161
  try {
3936
- const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME));
4162
+ const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME), { fresh: true });
3937
4163
  URL.revokeObjectURL(url);
3938
4164
  markerText = await blob.text();
3939
4165
  } catch {
@@ -3962,6 +4188,18 @@ async function e2eUnlock() {
3962
4188
  if (marker.v === 1) {
3963
4189
  e2eUpgradePw.value = e2ePw.value;
3964
4190
  e2eUpgradeOffer.value = true;
4191
+ } else if (escrowOfferState(marker, e2eEscrowKid.value) === 'offer') {
4192
+ /* wiring:e2 escrow-offer — a v2 folder that predates escrow here.
4193
+ * ⚠ Only after the unlock SUCCEEDED, and only by password: accepting
4194
+ * gives the operator a key to this folder, so the person asked has to
4195
+ * be the person who can already open it. A v1 folder is handled by the
4196
+ * branch above, which seals an escrow slot as part of the upgrade and
4197
+ * already discloses that — two offers on one unlock would be two
4198
+ * chances to get the disclosure wrong. */
4199
+ e2eEscrowPw.value = e2ePw.value;
4200
+ e2eEscrowOfferMode.value = 'unlock';
4201
+ e2eEscrowErr.value = '';
4202
+ e2eEscrowOffer.value = true;
3965
4203
  }
3966
4204
  e2ePw.value = '';
3967
4205
  } finally {
@@ -4204,7 +4442,7 @@ async function e2eRecoverUnlock(payload: { mode: 'recovery' | 'escrow'; value: s
4204
4442
  async function openRecoveryUnlock() {
4205
4443
  if (!e2eMarker.value && e2eRoot.value) {
4206
4444
  try {
4207
- const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME));
4445
+ const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME), { fresh: true });
4208
4446
  URL.revokeObjectURL(url);
4209
4447
  e2eMarker.value = parseMarker(await blob.text());
4210
4448
  } catch {
@@ -4243,6 +4481,101 @@ async function e2eDoUpgrade() {
4243
4481
  }
4244
4482
  }
4245
4483
 
4484
+ /* wiring:e2 escrow-offer ------------------------------------------- */
4485
+
4486
+ /** Whether this folder could be given an escrow slot, and whether its owner
4487
+ * has already answered. Drives the way-back button in the unlocked strip. */
4488
+ const e2eEscrowOfferState = computed(() => escrowOfferState(e2eMarker.value, e2eEscrowKid.value));
4489
+
4490
+ /** The way back. Somebody who said no last month can say yes today without
4491
+ * deleting and re-creating the folder — but the password is long gone from
4492
+ * memory, so they type it. That is not friction to be smoothed away: it is
4493
+ * the same proof of ownership the offer at unlock had, and handing the
4494
+ * operator a key deserves it. */
4495
+ function e2eOpenEscrowOffer() {
4496
+ e2eEscrowOfferMode.value = 'manual';
4497
+ e2eEscrowPw.value = '';
4498
+ e2eEscrowErr.value = '';
4499
+ e2eEscrowOffer.value = true;
4500
+ }
4501
+
4502
+ /** Write a marker back to the folder and adopt it as the one we are holding.
4503
+ * Both escrow-offer answers change only `.filex-e2e.json`. */
4504
+ async function e2eWriteMarker(next: E2eMarker) {
4505
+ const file = new File([JSON.stringify(next)], E2E_MARKER_NAME, { type: 'application/json' });
4506
+ await api.uploadMultipart(e2eRoot.value, [file]);
4507
+ e2eMarker.value = next;
4508
+ }
4509
+
4510
+ /** Accept: seal this folder's master key to the installation's escrow key.
4511
+ * No file is re-encrypted or moved — only the marker gains a slot. */
4512
+ async function e2eAcceptEscrow() {
4513
+ const marker = e2eMarker.value;
4514
+ const pub = e2eEscrowPub.value;
4515
+ if (!marker || !pub || !e2eRoot.value || e2eEscrowBusy.value) return;
4516
+ if (!e2eEscrowPw.value) {
4517
+ e2eEscrowErr.value = t('e2e.escrowoffer.password_required');
4518
+ return;
4519
+ }
4520
+ e2eEscrowBusy.value = true;
4521
+ e2eEscrowErr.value = '';
4522
+ try {
4523
+ // ⚠ The kid comes from the installation's CURRENT key, via the marker
4524
+ // addEscrowSlot writes — never from anything the UI is displaying. The
4525
+ // sibling bug went the other way (the dialog labelled a folder's slot
4526
+ // with the installation's kid); writing the installation's kid into a
4527
+ // slot sealed to some other key would be the same lie in reverse.
4528
+ const next = await addEscrowSlot(marker, e2eEscrowPw.value, pub);
4529
+ await e2eWriteMarker(next);
4530
+ e2eEscrowOffer.value = false;
4531
+ e2eEscrowPw.value = '';
4532
+ flashToast(t('e2e.escrowoffer.done'));
4533
+ } catch (err) {
4534
+ // A wrong password is the ordinary case here and reads as a typo, not
4535
+ // as a broken folder; anything else is worth reporting.
4536
+ if ((err as Error)?.name === 'E2eDecryptError') {
4537
+ e2eEscrowErr.value = t('e2e.escrowoffer.wrong_password');
4538
+ } else {
4539
+ e2eEscrowErr.value = t('e2e.escrowoffer.failed');
4540
+ emit('error', { message: (err as Error).message, context: { op: 'e2e-escrow-offer' } });
4541
+ }
4542
+ } finally {
4543
+ e2eEscrowBusy.value = false;
4544
+ }
4545
+ }
4546
+
4547
+ /** Not now. Recorded IN THE FOLDER, so the same person on another device is
4548
+ * not asked again and the answer survives a cleared browser. A question
4549
+ * that returns every single unlock is how people learn to click past
4550
+ * security dialogs without reading them. */
4551
+ async function e2eDeclineEscrow() {
4552
+ const marker = e2eMarker.value;
4553
+ if (!marker || !e2eRoot.value || e2eEscrowBusy.value) return;
4554
+ e2eEscrowBusy.value = true;
4555
+ try {
4556
+ await e2eWriteMarker(declineEscrowSlot(marker, new Date().toISOString()));
4557
+ e2eEscrowOffer.value = false;
4558
+ e2eEscrowPw.value = '';
4559
+ flashToast(t('e2e.escrowoffer.declined_toast'));
4560
+ } catch (err) {
4561
+ // ⚠ Fail towards asking again rather than towards silence: if the
4562
+ // refusal could not be written, the honest state is "not answered yet".
4563
+ e2eEscrowErr.value = t('e2e.escrowoffer.decline_failed');
4564
+ emit('error', { message: (err as Error).message, context: { op: 'e2e-escrow-decline' } });
4565
+ } finally {
4566
+ e2eEscrowBusy.value = false;
4567
+ }
4568
+ }
4569
+
4570
+ /** Close the way-back panel without answering anything. Distinct from
4571
+ * declining: they answered once already, and re-recording the same refusal
4572
+ * would overwrite the date it was actually made. */
4573
+ function e2eCloseEscrowOffer() {
4574
+ e2eEscrowOffer.value = false;
4575
+ e2eEscrowPw.value = '';
4576
+ e2eEscrowErr.value = '';
4577
+ }
4578
+
4246
4579
  /** Decline the offer. The folder keeps working exactly as it did, and the
4247
4580
  * prompt returns on the next unlock because the risk has not changed. */
4248
4581
  function e2eDeclineUpgrade() {
@@ -4311,6 +4644,9 @@ function closeRecoveryKey() {
4311
4644
  :nav-open="navToggleOn /* gezinti:g1 */"
4312
4645
  :nav-enabled="sideNavEnabled /* gezinti:g1 */"
4313
4646
  :view-modes="allowedViewModes /* gezinti:g1 */"
4647
+ :shell="driveShell ? 'drive' : 'classic' /* surucu:d1 */"
4648
+ :scope-label="driveScopeLabel /* surucu:d1 */"
4649
+ @open-palette="openPaletteWith /* surucu:d1 */"
4314
4650
  @toggle-inspector="toggleInspector /* koru:k1 */"
4315
4651
  @toggle-nav="toggleSideNav /* gezinti:g1 */"
4316
4652
  @open-theme="showThemeGallery = true /* wiring:c1 */"
@@ -4352,6 +4688,11 @@ function closeRecoveryKey() {
4352
4688
  :show-identity-surfaces="identitySurfaces"
4353
4689
  :can-write="canWriteHere && !atVirtualRoot && !trashActive"
4354
4690
  :locale="locale"
4691
+ :new-menu="driveShell /* surucu:d1 */"
4692
+ :can-request-files="canWriteHere && !atVirtualRoot && !trashActive && !navView /* surucu:d1 */"
4693
+ :quota="quotaSnapshot /* surucu:d1 */"
4694
+ :theme="themeMode /* surucu:d1 — the teleported New menu leaves .fe */"
4695
+ @request-files="openFileRequest /* surucu:d1 */"
4355
4696
  @toggle="toggleSideNav"
4356
4697
  @close="closeNavDrawer"
4357
4698
  @open-view="loadNavView"
@@ -4380,6 +4721,14 @@ function closeRecoveryKey() {
4380
4721
  class="fe__primary"
4381
4722
  :class="{ 'fe-pane--focus': mainPaneFocus } /* wiring:d1 — aktif panel vurgusu */"
4382
4723
  >
4724
+ <!-- surucu:d1 — the breadcrumb row. In the drive shell it also carries the
4725
+ two controls that belong to a LISTING rather than to the app (the view
4726
+ switcher and the details toggle), which is where the mockups put them
4727
+ and where the header then has room for one wide search field.
4728
+ Everywhere else the wrapper is `display: contents`, so the breadcrumb
4729
+ is the same flex child of `.fe__primary` it has always been — one
4730
+ Breadcrumb, not a second copy that can drift. -->
4731
+ <div :class="driveShell ? 'fe-subhead' : 'fe-subhead--plain'">
4383
4732
  <Breadcrumb
4384
4733
  :dirname="dirname"
4385
4734
  :adapter="adapter"
@@ -4392,6 +4741,49 @@ function closeRecoveryKey() {
4392
4741
  @crumb-context="onCrumbContext"
4393
4742
  @crumb-drop="onCrumbDropInto"
4394
4743
  />
4744
+ <div v-if="driveShell" class="fe-subhead__actions">
4745
+ <ViewSwitcher
4746
+ :view-mode="displayedViewMode"
4747
+ :locale="locale"
4748
+ :modes="allowedViewModes"
4749
+ @update:view-mode="setDisplayedViewMode($event)"
4750
+ />
4751
+ <button
4752
+ type="button"
4753
+ class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
4754
+ :class="{ 'is-active': showInspector }"
4755
+ :aria-pressed="showInspector"
4756
+ :title="t('toolbar.inspector')"
4757
+ :aria-label="t('toolbar.inspector')"
4758
+ data-testid="subhead-inspector"
4759
+ @click="toggleInspector"
4760
+ >
4761
+ <svg
4762
+ class="fe-ficon"
4763
+ viewBox="0 0 24 24"
4764
+ fill="none"
4765
+ stroke="currentColor"
4766
+ stroke-width="1.8"
4767
+ stroke-linecap="round"
4768
+ aria-hidden="true"
4769
+ focusable="false"
4770
+ >
4771
+ <circle cx="12" cy="12" r="9" />
4772
+ <path d="M12 11v5" />
4773
+ <circle cx="12" cy="7.6" r="1" fill="currentColor" stroke="none" />
4774
+ </svg>
4775
+ </button>
4776
+ </div>
4777
+ </div>
4778
+ <FilterBar
4779
+ v-if="driveShell && !atVirtualRoot"
4780
+ :value="driveFilters"
4781
+ :locale="locale"
4782
+ :theme="themeMode"
4783
+ :shown="displayFiles.length"
4784
+ :total="files.length"
4785
+ @update:value="setDriveFilters"
4786
+ />
4395
4787
 
4396
4788
  <!-- Live presence: who else is viewing this folder (empty → nothing shown).
4397
4789
  When the live socket is unavailable the same strip carries a small
@@ -4438,9 +4830,76 @@ function closeRecoveryKey() {
4438
4830
  </button>
4439
4831
  </div>
4440
4832
  </div>
4833
+ <!-- wiring:e2 escrow-offer — an existing folder that predates escrow on
4834
+ this installation. Shown only after an unlock SUCCEEDED, because
4835
+ accepting hands the operator a permanent second key to this folder
4836
+ and the only person entitled to do that is the one who can already
4837
+ open it. Doing nothing leaves the folder exactly as it is. -->
4838
+ <div v-if="e2eEscrowOffer" class="fe-e2e-upgrade fe-e2e-upgrade--escrow" role="alert">
4839
+ <div class="fe-e2e-upgrade__text">
4840
+ <strong>{{ t('e2e.escrowoffer.title') }}</strong>
4841
+ <p>{{ t('e2e.escrowoffer.body') }}</p>
4842
+ <p class="fe-e2e-upgrade__escrow">
4843
+ {{ t('e2e.escrowoffer.consequence') }}
4844
+ <a :href="e2eEscrowDocsUrl" target="_blank" rel="noopener noreferrer">
4845
+ {{ t('e2e.escrowoffer.learn_more') }}
4846
+ </a>
4847
+ </p>
4848
+ <p v-if="e2eEscrowKid" class="fe-e2e-upgrade__escrow">
4849
+ {{ t('e2e.recover.escrow_kid') }}: <code>{{ e2eEscrowKid }}</code>
4850
+ </p>
4851
+ <!-- The way-back path has no password in memory any more, so it is
4852
+ typed. The same proof of ownership the unlock path already had. -->
4853
+ <label v-if="e2eEscrowOfferMode === 'manual'" class="fe-e2e-upgrade__pw">
4854
+ <span>{{ t('e2e.escrowoffer.password_label') }}</span>
4855
+ <input
4856
+ v-model="e2eEscrowPw"
4857
+ type="password"
4858
+ class="fe-input"
4859
+ autocomplete="current-password"
4860
+ :disabled="e2eEscrowBusy"
4861
+ @keyup.enter="e2eAcceptEscrow"
4862
+ />
4863
+ </label>
4864
+ <p v-if="e2eEscrowErr" class="fe-form__error" role="alert">{{ e2eEscrowErr }}</p>
4865
+ </div>
4866
+ <div class="fe-e2e-upgrade__actions">
4867
+ <button
4868
+ type="button"
4869
+ class="fe-btn"
4870
+ :disabled="e2eEscrowBusy"
4871
+ @click="e2eEscrowOfferMode === 'manual' ? e2eCloseEscrowOffer() : e2eDeclineEscrow()"
4872
+ >
4873
+ {{
4874
+ e2eEscrowOfferMode === 'manual'
4875
+ ? t('e2e.escrowoffer.cancel')
4876
+ : t('e2e.escrowoffer.decline')
4877
+ }}
4878
+ </button>
4879
+ <button
4880
+ type="button"
4881
+ class="fe-btn fe-btn--primary"
4882
+ :disabled="e2eEscrowBusy"
4883
+ @click="e2eAcceptEscrow"
4884
+ >
4885
+ {{ e2eEscrowBusy ? t('e2e.escrowoffer.busy') : t('e2e.escrowoffer.accept') }}
4886
+ </button>
4887
+ </div>
4888
+ </div>
4441
4889
  <div v-if="e2eUnlocked" class="fe-e2e-strip" role="status">
4442
4890
  <span class="fe-e2e-strip__icon" aria-hidden="true">🔒</span>
4443
4891
  <span class="fe-e2e-strip__label">{{ t('e2e.strip.label') }}</span>
4892
+ <!-- The way back for somebody who declined. Quiet, but present: a
4893
+ refusal that could not be reversed without deleting the folder
4894
+ would not be a decision, it would be a trap. -->
4895
+ <button
4896
+ v-if="e2eEscrowOfferState !== 'n/a' && !e2eEscrowOffer"
4897
+ type="button"
4898
+ class="fe-btn fe-e2e-strip__btn"
4899
+ @click="e2eOpenEscrowOffer"
4900
+ >
4901
+ {{ t('e2e.escrowoffer.strip_action') }}
4902
+ </button>
4444
4903
  <button type="button" class="fe-btn fe-e2e-strip__btn" @click="e2eLock">
4445
4904
  {{ t('e2e.strip.lock') }}
4446
4905
  </button>
@@ -4675,6 +5134,36 @@ function closeRecoveryKey() {
4675
5134
  </svg>
4676
5135
  <p class="fe-state__title">{{ t('empty.trash.title') }}</p>
4677
5136
  </div>
5137
+ <!-- surucu:d1 — the folder HAS rows and the filters hid all of them.
5138
+ "This folder is empty" would be false, and the way back is the chip
5139
+ row just above, so the message names it and offers the button. -->
5140
+ <div
5141
+ v-else-if="!loading && filtersOn && displayFiles.length === 0 && files.length > 0"
5142
+ class="fe-state"
5143
+ data-testid="empty-filtered"
5144
+ >
5145
+ <svg
5146
+ class="fe-state__art"
5147
+ viewBox="0 0 120 100"
5148
+ width="110"
5149
+ height="92"
5150
+ fill="none"
5151
+ stroke="currentColor"
5152
+ stroke-width="2"
5153
+ stroke-linecap="round"
5154
+ stroke-linejoin="round"
5155
+ aria-hidden="true"
5156
+ >
5157
+ <path d="M26 28h68L68 58v24l-16 8V58z" />
5158
+ </svg>
5159
+ <p class="fe-state__title">{{ t('filter.empty.title') }}</p>
5160
+ <p class="fe-state__hint">{{ t('filter.empty.hint') }}</p>
5161
+ <div class="fe-state__actions">
5162
+ <button type="button" class="fe-btn" @click="clearDriveFilters">
5163
+ {{ t('filter.clear') }}
5164
+ </button>
5165
+ </div>
5166
+ </div>
4678
5167
  <!-- Loaded, zero files, no search: the real empty-folder state. The
4679
5168
  upload affordances follow write permission (RBAC viewers only get
4680
5169
  the title). -->
@@ -4707,7 +5196,7 @@ function closeRecoveryKey() {
4707
5196
  </div>
4708
5197
  <ListView
4709
5198
  v-else-if="viewMode === 'list'"
4710
- :files="files"
5199
+ :files="displayFiles /* surucu:d1 */"
4711
5200
  :selected="selection.selected.value"
4712
5201
  :clipped="clippedPaths"
4713
5202
  :show-parent-path="!!searchQuery"
@@ -4715,6 +5204,7 @@ function closeRecoveryKey() {
4715
5204
  :loading="loading"
4716
5205
  :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
4717
5206
  :starred-ids="starredIds"
5207
+ :star-enabled="identitySurfaces"
4718
5208
  :api-base="props.config.apiBase ?? ''"
4719
5209
  :auth-headers="() => buildAuthHeaders()"
4720
5210
  :auth-credentials="api.credentialsMode()"
@@ -4727,7 +5217,8 @@ function closeRecoveryKey() {
4727
5217
  />
4728
5218
  <GridView
4729
5219
  v-else-if="viewMode === 'grid' /* wiring:d2 — v-else → v-else-if (3. mod eklendi) */"
4730
- :files="files"
5220
+ :files="displayFiles /* surucu:d1 */"
5221
+ :sections="driveShell /* surucu:d1 */"
4731
5222
  :selected="selection.selected.value"
4732
5223
  :clipped="clippedPaths"
4733
5224
  :show-parent-path="!!searchQuery"
@@ -4736,6 +5227,7 @@ function closeRecoveryKey() {
4736
5227
  :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
4737
5228
  :thumb-src="thumbs.src"
4738
5229
  :starred-ids="starredIds"
5230
+ :star-enabled="identitySurfaces"
4739
5231
  :api-base="props.config.apiBase ?? ''"
4740
5232
  :auth-headers="() => buildAuthHeaders()"
4741
5233
  :auth-credentials="api.credentialsMode()"
@@ -4749,7 +5241,7 @@ function closeRecoveryKey() {
4749
5241
  <!-- wiring:d2 — galeri görünümü (GridView ile aynı event sözleşmesi) -->
4750
5242
  <GalleryView
4751
5243
  v-else
4752
- :files="files"
5244
+ :files="displayFiles /* surucu:d1 */"
4753
5245
  :selected="selection.selected.value"
4754
5246
  :clipped="clippedPaths"
4755
5247
  :show-parent-path="!!searchQuery"
@@ -4757,6 +5249,7 @@ function closeRecoveryKey() {
4757
5249
  :loading="loading"
4758
5250
  :thumb-src="thumbs.src"
4759
5251
  :starred-ids="starredIds"
5252
+ :star-enabled="identitySurfaces"
4760
5253
  :api-base="props.config.apiBase ?? ''"
4761
5254
  :auth-headers="() => buildAuthHeaders()"
4762
5255
  :auth-credentials="api.credentialsMode()"
@@ -4793,6 +5286,7 @@ function closeRecoveryKey() {
4793
5286
  :view-mode="paneViewMode /* ui-fix */"
4794
5287
  :thumb-src="thumbs.src /* ui-fix */"
4795
5288
  :trash-visible="config.trashVisible !== false /* ui-fix — çöp satırı simetrisi */"
5289
+ :nav-offers-trash="navOffersTrash /* surucu:d1 — aynı kapı iki kez açılmaz */"
4796
5290
  @navigate="onPaneNavigate"
4797
5291
  @activate="activePane = 'split'"
4798
5292
  @close="closeSplit"
@@ -4815,7 +5309,9 @@ function closeRecoveryKey() {
4815
5309
  :locale="locale"
4816
5310
  :narrow="isNarrow"
4817
5311
  :thumb-src="thumbs.src"
5312
+ :tabs="driveShell /* surucu:d1 */"
4818
5313
  @close="closeInspector"
5314
+ @share-created="onInspectorShareCreated /* surucu:d1 */"
4819
5315
  @manage-permissions="onInspectorManage"
4820
5316
  @toast="flashToast"
4821
5317
  @changed="() => load()"
@@ -4974,8 +5470,8 @@ function closeRecoveryKey() {
4974
5470
  :open="showRecoveryUnlock"
4975
5471
  :locale="locale"
4976
5472
  :has-recovery="markerHasRecovery(e2eMarker)"
4977
- :has-escrow="markerHasEscrow(e2eMarker) && !!e2eEscrowKid"
4978
- :escrow-kid="e2eEscrowKid"
5473
+ :escrow-state="escrowAvailability(e2eMarker, e2eEscrowKid)"
5474
+ :escrow-kid="e2eMarker?.esc?.kid || e2eEscrowKid"
4979
5475
  :busy="e2eRecoverBusy"
4980
5476
  :error="e2eRecoverErr"
4981
5477
  @close="showRecoveryUnlock = false"
@@ -5042,7 +5538,8 @@ function closeRecoveryKey() {
5042
5538
  :size="typeof permTarget.size === 'number' ? permTarget.size : undefined"
5043
5539
  :locale="locale === 'en' ? 'en' : 'tr'"
5044
5540
  :share-max-ttl-days="shareMaxTtlDays"
5045
- @close="showPerm = false"
5541
+ :initial-tab="permInitialTab /* surucu:d1 */"
5542
+ @close="showPerm = false; permInitialTab = undefined"
5046
5543
  />
5047
5544
 
5048
5545
  <!-- Recently-opened tray. Anchored to the toolbar trigger via fixed
@@ -5108,6 +5605,7 @@ function closeRecoveryKey() {
5108
5605
 
5109
5606
  <!-- cila:c wiring — command palette (Ctrl/Cmd+K) + shortcuts help (?) -->
5110
5607
  <CommandPalette
5608
+ :initial-query="paletteSeed /* surucu:d1 */"
5111
5609
  :open="showPalette"
5112
5610
  :locale="locale"
5113
5611
  :files="files"