@brftech/filex-core 0.39.1 → 0.41.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 (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -24,7 +24,7 @@ import type {
24
24
  Capabilities,
25
25
  } from './types/FileNode';
26
26
  import { isExternalUsable } from './types/FileNode';
27
- import { useFileApi, type GlobalSearchHit } from './composables/useFileApi';
27
+ import { useFileApi, type GlobalSearchHit, type ManagerResponse } from './composables/useFileApi';
28
28
  import {
29
29
  useUploadChunked,
30
30
  isStagedUnsupported,
@@ -42,28 +42,50 @@ import PresenceBar from './components/PresenceBar.vue';
42
42
  import Toolbar, { type SelectionMode } from './components/Toolbar.vue';
43
43
  import StarButton from './components/StarButton.vue';
44
44
  import TagPicker from './components/TagPicker.vue';
45
+ import {
46
+ activeSortDir,
47
+ activeSortKey,
48
+ applySort,
49
+ defaultSortDir,
50
+ globalSort,
51
+ setSortLocale,
52
+ type ListingOrder,
53
+ } from './lib/sortOrder'; /* surucu:d1-sort */
54
+ import {
55
+ attachViewPrefsStore,
56
+ folderKey as makeFolderKey,
57
+ folderMemoryEnabled,
58
+ folderPrefs,
59
+ rememberFolder,
60
+ touchFolder,
61
+ viewPrefsReady,
62
+ } from './lib/viewPrefs'; /* tablo:t1 */
63
+ import { resolveUiProfile } from './lib/uiProfile';
45
64
  import RecentlyOpened from './components/RecentlyOpened.vue';
46
- import Breadcrumb from './components/Breadcrumb.vue';
47
- import ListView from './components/ListView.vue';
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
65
  import {
52
66
  EMPTY_FILTERS,
53
67
  applyFilters,
54
68
  filtersActive,
55
69
  type DriveFilters,
56
70
  } from './lib/fileFilters' /* surucu:d1 */;
57
- import GalleryView from './components/GalleryView.vue'; /* wiring:d2 */
58
71
  import ContextMenu, { type ContextAction } from './components/ContextMenu.vue';
59
72
  import UploadProgress from './components/UploadProgress.vue';
60
73
  import PendingOpsTray from './components/PendingOpsTray.vue';
61
74
  import InspectorPanel from './components/InspectorPanel.vue'; /* koru:k1 */
62
- import SideNav from './components/SideNav.vue'; /* gezinti:g1 */
75
+ import SideNav, { type NavDest } from './components/SideNav.vue'; /* gezinti:g1 */
76
+ import HomeView from './components/HomeView.vue'; /* gorunum:v3-shell */
63
77
  import ConnectionsPanel from './components/ConnectionsPanel.vue'; /* gezinti:g1 */
64
78
  import TokensPanel from './components/TokensPanel.vue'; /* gezinti:g1 */
65
79
  /* cila:c wiring */
66
80
  import CommandPalette from './components/CommandPalette.vue';
81
+ import AdvancedSearch from './components/AdvancedSearch.vue' /* gorunum:v1-advsearch */;
82
+ import {
83
+ advQueryString,
84
+ advSearchTruncated,
85
+ type AdvCountResult,
86
+ type AdvScope,
87
+ type AdvSearchRequest,
88
+ } from './lib/advSearch' /* gorunum:v1-advsearch */;
67
89
  import ShortcutsHelp from './components/ShortcutsHelp.vue';
68
90
  /* /cila:c wiring */
69
91
  /* wiring:c1 — tema galerisi */
@@ -76,6 +98,10 @@ import {
76
98
  type ThemeModePref,
77
99
  } from './lib/themes';
78
100
  /* /wiring:c1 */
101
+ /* zaman:z3 — the embed's own time-zone setting + this instance's tiers */
102
+ import TimeZoneDialog from './components/TimeZoneDialog.vue';
103
+ import { useExplorerTimeZone } from './composables/useExplorerTimeZone';
104
+ import { connectionsBase } from './composables/useConnections';
79
105
  /* wiring:c2 — shortcut settings modal + Space quick-look overlay */
80
106
  import ShortcutSettings from './components/ShortcutSettings.vue';
81
107
  import QuickLook from './components/QuickLook.vue';
@@ -89,7 +115,7 @@ import OnboardingTour from './components/OnboardingTour.vue';
89
115
  /* /wiring:c4 */
90
116
  /* wiring:d1 — tabs + per-tab split */
91
117
  import TabBar from './components/TabBar.vue';
92
- import SecondaryPane from './components/SecondaryPane.vue';
118
+ import FilePane from './components/FilePane.vue';
93
119
  import { useTabs, type TabState } from './composables/useTabs';
94
120
  /* /wiring:d1 */
95
121
  /* wiring:e2 — end-to-end encrypted folders (docs/E2E-ENCRYPTION.md) */
@@ -127,17 +153,21 @@ import {
127
153
  import {
128
154
  filterListing,
129
155
  virtualSegmentLabel,
156
+ isVirtualViewPath,
130
157
  makeTagSegment,
131
158
  tagOfPath,
132
- VIRTUAL_SEGMENTS,
133
159
  showHiddenFiles,
134
160
  setShowHiddenFiles,
135
161
  injectTrashRow,
136
162
  hydrateTrashRow as hydrateTrashRowShared,
137
163
  } from './lib/listing';
164
+ import { iconFamilyFor, isStorageRow } from './lib/fileIcons'; /* pane:p1 — the storage-row predicate's one home */
165
+ import { actionIconSvg } from './lib/actionIcons'; /* inceleme:r1 — the drop overlay's mark, off the emoji font */
138
166
  import { setNodeStarred } from './lib/star';
139
167
  import { fetchAllTags, fetchTaggedRows, invalidateTagCache } from './lib/tags';
140
168
  import { resolveTransfer, type TransferIntent } from './lib/transfer';
169
+ import { downloadArchive } from './lib/downloadSelection'; /* tasi:m1 */
170
+ import { labelOfWire } from './lib/destinationTree'; /* tasi:m1 */
141
171
  import {
142
172
  activeNativeDrag,
143
173
  beginNativeDrag,
@@ -152,12 +182,14 @@ import {
152
182
  } from './lib/dragOut';
153
183
 
154
184
  import NewFolderModal from './modals/NewFolderModal.vue';
185
+ import NewDocumentModal from './modals/NewDocumentModal.vue'; /* belge:n1 */
155
186
  import RenameModal from './modals/RenameModal.vue';
156
187
  import DeleteConfirmModal from './modals/DeleteConfirmModal.vue';
157
- import ShareModal from './modals/ShareModal.vue';
188
+ import Modal from './modals/Modal.vue'; /* tablo:t1 — the empty-trash confirmation */
158
189
  import PreviewModal from './modals/PreviewModal.vue';
159
190
  import ConvertModal from './modals/ConvertModal.vue';
160
191
  import PermissionsModal from './modals/PermissionsModal.vue';
192
+ import DestinationPickerModal from './modals/DestinationPickerModal.vue'; /* tasi:m1 */
161
193
  import { resolveLocale } from './locales/resolve';
162
194
 
163
195
  const props = defineProps<{
@@ -177,6 +209,23 @@ const emit = defineEmits<{
177
209
  // Lets a host (e.g. the Explore page's realtime layer) track the current
178
210
  // folder without reaching into internal state.
179
211
  (e: 'navigate', p: { path: string }): void;
212
+ /**
213
+ * gorunum:v2-topbar — the user asked for a refresh (the header's button or
214
+ * the palette's command; both reach `refreshAll`).
215
+ *
216
+ * The explorer reloads the LISTING itself; this is for the half it cannot
217
+ * know about. `config.storages` is the host's answer to "which drives may I
218
+ * show you", computed before the explorer was mounted, and nothing inside
219
+ * here can recompute it — so a drive added from somewhere else stayed
220
+ * invisible until the whole page was reloaded. The Explore page used to
221
+ * paper over that with a Refresh button of its own in the page bar; the bar
222
+ * is gone, so the one Refresh has to mean both halves.
223
+ *
224
+ * ⚠ A notification, not a request: the explorer does not wait for the host
225
+ * and does not care whether it does anything. An embedder with a fixed
226
+ * storage list simply ignores it.
227
+ */
228
+ (e: 'refresh'): void;
180
229
  }>();
181
230
 
182
231
  // --------------------------------------------------------------------
@@ -189,7 +238,17 @@ const api = useFileApi(props.config);
189
238
  // helpers) need `t()` at runtime, so the catalogue must be constructed before
190
239
  // they are wired. Depends only on props — safe this early.
191
240
  const locale = computed(() => resolveLocale(props.config.locale));
192
- const { t } = useLocale(locale);
241
+ /* surucu:d1-sort the alphabet the `type` key sorts in (lib/sortOrder sorts
242
+ * by the word the Type column PRINTS, so "Image" and "Görsel" each fall in
243
+ * their own order).
244
+ * ⚠ Pushed from HERE as well as from FilterBar and ListView, and that is not a
245
+ * third copy of a decision — it is one value, pushed by the component that
246
+ * always exists. This file is now the one that sorts; the filter row is absent
247
+ * at a virtual root and the list is absent in grid and gallery, so relying on
248
+ * either would leave the comparator on a stale alphabet exactly when they are
249
+ * not mounted. */
250
+ watch(locale, (l) => setSortLocale(l), { immediate: true });
251
+ const { t, formatSize } = useLocale(locale); /* tablo:t1 — the empty-trash confirmation names the space */
193
252
 
194
253
  // Live collaboration (WebSocket file-change events + presence), bundled into the
195
254
  // core so every consumer — the native panel AND the embedded webcomponent —
@@ -289,6 +348,20 @@ function retryLoad() {
289
348
  }
290
349
 
291
350
  const VIEW_MODE_KEY = 'brf-file-explorer:view-mode';
351
+ /**
352
+ * tablo:t1 — true while a folder's REMEMBERED setup is being restored, as
353
+ * opposed to a person choosing one.
354
+ *
355
+ * ⚠⚠ Without this the whole design inverts. `brf-file-explorer:view-mode` is
356
+ * the GLOBAL default — what a folder nobody has configured opens as — and the
357
+ * per-folder memory is layered over it. If restoring a folder's grid view also
358
+ * wrote the global key, then merely WALKING INTO one folder you once set to
359
+ * grid would make grid the default for the entire product, and the person
360
+ * would have no way to tell which folder did it. Restoring is not choosing.
361
+ * The same flag also keeps the watcher below from re-recording what it just
362
+ * applied.
363
+ */
364
+ let restoringFolderView = false;
292
365
  const viewMode = customRef<ViewMode>((track, trigger) => {
293
366
  let value: ViewMode = (() => {
294
367
  try {
@@ -307,10 +380,12 @@ const viewMode = customRef<ViewMode>((track, trigger) => {
307
380
  set(next) {
308
381
  if (next === value) return;
309
382
  value = next;
310
- try {
311
- localStorage.setItem(VIEW_MODE_KEY, next);
312
- } catch {
313
- /* quota */
383
+ if (!restoringFolderView) {
384
+ try {
385
+ localStorage.setItem(VIEW_MODE_KEY, next);
386
+ } catch {
387
+ /* quota */
388
+ }
314
389
  }
315
390
  trigger();
316
391
  },
@@ -338,15 +413,221 @@ const trashActive = computed(() => trashMode.value);
338
413
  * pattern is trashMode's, generalised — including the part that matters most,
339
414
  * that load() clears the mode, or the view sticks and every later navigation
340
415
  * renders under the wrong heading. */
341
- type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash' | 'tag';
416
+ type NavView = '' | 'home' | 'recent' | 'starred' | 'shared' | 'trash' | 'tag';
342
417
  const navView = ref<NavView>('');
343
418
  /** Where the view was entered from, so "up" goes back there. */
344
419
  const navViewOrigin = ref<string>('');
420
+
421
+ /**
422
+ * gorunum:v1 — when a row's NAME is not enough to know where it is.
423
+ *
424
+ * Recent, Starred, Shared with me and a tag view each draw rows gathered from
425
+ * every folder in the storage, so two files called `report.pdf` are two
426
+ * identical lines. The listing already knows how to print a row's folder — it
427
+ * did it for search hits only. Trash is left out on purpose: a trashed row's
428
+ * stored path is its trash key, not the folder it came from, so the column
429
+ * would print an internal name.
430
+ */
431
+ const crossFolderView = computed(
432
+ () => navView.value !== '' && navView.value !== 'trash' && navView.value !== 'home',
433
+ );
434
+
435
+ /* === tablo:t1 — per-folder view memory ==================================
436
+ * "x folder'ında son görünüm nasıl kaldı ise öyle görünümde göstermemiz
437
+ * lazım." A folder opens the way you left it. The rule, the cap and the split
438
+ * between what is per-folder and what is a global preference are all argued in
439
+ * `lib/viewPrefs`; this is only the wiring — the two moments the explorer is
440
+ * the one that knows something: a navigation ended, and a person changed a
441
+ * view.
442
+ */
443
+
444
+ /** An embed's opt-out. A product mounting filex in a two-inch panel does not
445
+ * want a remembered gallery view arriving from somebody's main window. */
446
+ const folderMemoryOn = computed(() => props.config.rememberFolderView !== false);
447
+
448
+ /**
449
+ * The key the folder on screen is remembered under.
450
+ *
451
+ * `currentPath` is already the qualified `<storage>/<rel>` form, so the first
452
+ * segment is the storage ref — and the ref is swapped for the storage's
453
+ * immutable `uid` when the host supplies one (`config.storages[].uid`), which
454
+ * is the difference between a memory that survives a rename and one that does
455
+ * not. See the note on `folderKey` for what happens until it does.
456
+ *
457
+ * The virtual views (`.recent`, `.starred`, `.tag~x`) have no storage and so
458
+ * key on their own sentinel — which is how Recent gets a remembered sort of
459
+ * its own without a special case anywhere.
460
+ */
461
+ const currentFolderKey = computed(() => {
462
+ if (!folderMemoryOn.value) return '';
463
+ const path = String(currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
464
+ if (!path) return '';
465
+ const [first, ...rest] = path.split('/');
466
+ const st = (props.config.storages ?? []).find((s) => s.name === first);
467
+ return makeFolderKey(st?.uid || first, rest.join('/'));
468
+ });
469
+
470
+ /** The GLOBAL default view mode — what a folder nobody has configured opens
471
+ * as. Read from the same key the `viewMode` ref persists to, so there is one
472
+ * answer rather than a second copy drifting beside it. */
473
+ function globalViewMode(): ViewMode {
474
+ try {
475
+ const stored = localStorage.getItem(VIEW_MODE_KEY);
476
+ if (stored === 'list' || stored === 'grid' || stored === 'gallery') return stored;
477
+ } catch {
478
+ /* private mode */
479
+ }
480
+ return props.config.viewMode ?? 'list';
481
+ }
482
+
483
+ /** What we last RESTORED, so the recorder below can tell a restore's echo from
484
+ * a person's choice.
485
+ *
486
+ * ⚠⚠ A signature and not a boolean flag, and the difference is a bug I would
487
+ * otherwise have shipped: Vue's watchers are asynchronous, so a `restoring =
488
+ * true … restoring = false` fence around the assignment is already back down
489
+ * by the time the recorder runs and every navigation would record itself as a
490
+ * deliberate choice — which would mean walking through a folder configures it.
491
+ * The `viewMode` setter's own guard CAN be a flag because a `customRef` setter
492
+ * runs synchronously inside the fence; these two need different mechanisms
493
+ * because they run at different times. */
494
+ let appliedSig = '';
495
+
496
+ function viewSig(key: string): string {
497
+ return `${key}|${viewMode.value}|${activeSortKey()}|${activeSortDir()}`;
498
+ }
499
+
500
+ /** A navigation ended: put this folder back the way it was left. */
501
+ function applyFolderView(key: string) {
502
+ if (!key) return;
503
+ touchFolder(key); // LRU clock — only bumps folders already remembered
504
+ const p = folderPrefs(key);
505
+ restoringFolderView = true;
506
+ try {
507
+ /* ⚠ The `else` halves matter as much as the `if`s. Without them a folder
508
+ * with no memory of its own would inherit whatever the PREVIOUS folder was
509
+ * restored to — walk from a remembered gallery into a plain folder and it
510
+ * comes up as a gallery, which reads as the memory leaking rather than as
511
+ * a default holding. A folder with no memory follows the global default,
512
+ * and that has to be asserted, not assumed. */
513
+ const wantView = p?.v ?? globalViewMode();
514
+ if (wantView !== viewMode.value) viewMode.value = wantView;
515
+ if (p?.k) applySort(p.k, p.d ?? defaultSortDir(p.k));
516
+ else {
517
+ const g = globalSort();
518
+ applySort(g.key, g.dir);
519
+ }
520
+ } finally {
521
+ restoringFolderView = false;
522
+ }
523
+ appliedSig = viewSig(key);
524
+ }
525
+
526
+ /**
527
+ * tablo:t1 — hand `lib/viewPrefs` its transport.
528
+ *
529
+ * The document lives on the user row (migration 00039), so this is the one
530
+ * place that knows the base URL, the auth headers and the credentials mode.
531
+ * Started in `onMounted`, i.e. in the same turn as the first listing — the
532
+ * prefs are a single row and the listing has to walk a storage, so the prefs
533
+ * land first in practice, and nothing is applied to a folder until they do.
534
+ */
535
+ onMounted(() => {
536
+ const base = props.config.apiBase ?? '';
537
+ const url = `${base}/api/files/manager/view-prefs`;
538
+ attachViewPrefsStore({
539
+ async load() {
540
+ const res = await fetch(url, {
541
+ headers: await buildAuthHeaders(),
542
+ credentials: api.credentialsMode(),
543
+ });
544
+ /* ⚠ A 401 is not an error here, it is an ANSWER: an app token or a
545
+ * public share link has no person to remember anything for. Returning
546
+ * null degrades to "remember nothing, write nothing", which is what the
547
+ * module does with it — no retry loop, no console noise. */
548
+ if (!res.ok) return null;
549
+ const body = (await res.json()) as { prefs?: unknown };
550
+ return body?.prefs ?? null;
551
+ },
552
+ save(doc) {
553
+ void (async () => {
554
+ try {
555
+ const payload = JSON.stringify({ prefs: doc });
556
+ await fetch(url, {
557
+ method: 'PUT',
558
+ headers: { ...(await buildAuthHeaders()), 'Content-Type': 'application/json' },
559
+ credentials: api.credentialsMode(),
560
+ body: payload,
561
+ /* ⚠ `keepalive` is what lets the save fired on `pagehide` outlive
562
+ * the page — but browsers cap a keepalive body at 64 KB and reject
563
+ * the request outright above it. The document is capped far below
564
+ * that (300 folders ≈ 33 KB), so this only ever guards the
565
+ * pathological case; sending it without keepalive is strictly
566
+ * better than having it rejected. */
567
+ keepalive: payload.length < 60000,
568
+ });
569
+ } catch {
570
+ /* Fire and forget. A view preference is never worth a toast, and the
571
+ next save carries the whole document again. */
572
+ }
573
+ })();
574
+ },
575
+ });
576
+ });
577
+
578
+ /* ⚠ `flush: 'post'` so this is the LAST word in the tick. A tab switch sets
579
+ * the path and the tab's own remembered view mode in the same turn; running
580
+ * before it would apply the folder's memory and then have the tab overwrite
581
+ * it, which is the one arrangement in which the feature silently does nothing
582
+ * on exactly the gesture people use most.
583
+ *
584
+ * ⚠⚠ `viewPrefsReady()` is a DEPENDENCY, not a guard, and that is what stops
585
+ * the flash the other way round: the first folder is usually open before the
586
+ * document lands, so this has to re-run when it does. Reading it here means
587
+ * the applier fires once more the moment the answer exists, and the folder
588
+ * settles into its remembered view without anybody having navigated again. */
589
+ watch(
590
+ [currentFolderKey, () => viewPrefsReady()],
591
+ ([key, rdy]) => {
592
+ if (!rdy) return;
593
+ applyFolderView(key);
594
+ },
595
+ { immediate: true, flush: 'post' },
596
+ );
597
+
598
+ /** A person changed a view. Record it against the folder — and note that the
599
+ * GLOBAL default was already written by the control they used (`setSort`
600
+ * persists; the `viewMode` setter persists), which is what makes "Apply to
601
+ * all folders" a pure forget rather than a second write. */
602
+ watch(
603
+ () => [currentFolderKey.value, viewMode.value, activeSortKey(), activeSortDir()] as const,
604
+ ([key, v, k, d]) => {
605
+ /* ⚠ Nothing is recorded before the document has landed. Until then the
606
+ * state on screen is this session's defaults, not the person's choices,
607
+ * and writing it back would overwrite everything they had arranged with
608
+ * whatever the app happened to boot into. */
609
+ if (!key || !viewPrefsReady()) return;
610
+ const sig = viewSig(key);
611
+ if (sig === appliedSig) return; // the restore, echoing back
612
+ appliedSig = sig;
613
+ rememberFolder(key, { v, k, d });
614
+ },
615
+ /* ⚠⚠ `post`, and registered AFTER the applier, for a reason that is not
616
+ * stylistic. A navigation changes `currentFolderKey`, which is a dependency
617
+ * of BOTH watchers. Left pre-flush this one would run first, while the view
618
+ * state is still the folder you just LEFT, and write that folder's view mode
619
+ * and sort against the folder you just arrived in — every walk through the
620
+ * tree quietly configuring the next folder with the last one's setup. Post
621
+ * puts the applier first; by the time this runs, `appliedSig` matches and it
622
+ * correctly does nothing. */
623
+ { flush: 'post' },
624
+ );
345
625
  /** The tag being browsed while navView === 'tag' ('' otherwise). */
346
626
  const navTag = ref<string>('');
347
627
  /** Sentinel parked in `dirname` so the breadcrumb can label the view. The tag
348
628
  * view's sentinel is built per tag (`makeTagSegment`) — see lib/listing. */
349
629
  const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> = {
630
+ home: '.home',
350
631
  recent: '.recent',
351
632
  starred: '.starred',
352
633
  shared: '.shared',
@@ -360,12 +641,16 @@ const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> =
360
641
  * true of the four shipped views; the tag view would have inherited it.)
361
642
  */
362
643
  function virtualViewOf(path: string): { kind: Exclude<NavView, ''>; tag: string } | null {
644
+ /* ⚠ The "is this a sentinel at all?" half is `lib/listing`'s
645
+ `isVirtualViewPath`, not a second reading of the map here: FilePane has to
646
+ answer the same question before it qualifies a path (a qualified sentinel
647
+ becomes an adapter and stops being translatable), and two answers to it is
648
+ how the breadcrumb ended up printing `.starred`. This function adds only
649
+ what the panel needs on top: WHICH view. */
650
+ if (!isVirtualViewPath(path)) return null;
363
651
  const clean = String(path ?? '').replace(/^\/+|\/+$/g, '');
364
- if (!clean) return null;
365
652
  const tag = tagOfPath(clean);
366
653
  if (tag) return { kind: 'tag', tag };
367
- const key = VIRTUAL_SEGMENTS[clean];
368
- if (!key) return null;
369
654
  const kind = clean.slice(1) as Exclude<NavView, '' | 'tag'>;
370
655
  return { kind, tag: '' };
371
656
  }
@@ -400,11 +685,48 @@ const atVirtualRoot = computed(() => {
400
685
  // gezinti:g1 — a virtual view (Recent / Starred / Shared with me) has no
401
686
  // backend folder behind it either. "New folder" there would have to invent a
402
687
  // destination, and "upload" would have to guess one.
688
+ //
689
+ // ⚠ gorunum:v3-shell — Home is in that set, and it is now the LANDING view,
690
+ // so the "+ New" menu opens with its three rows disabled on the first screen
691
+ // a person sees. That is deliberate and it is not a bug to "fix" by picking
692
+ // a drive: with several storages there is no honest answer to "upload where",
693
+ // and an entry that silently chose one would put somebody's file in a place
694
+ // they did not name. (With exactly one visible storage the question does not
695
+ // arise — `soleStorageName` opens that storage as the root, so "My files" is
696
+ // a real folder.) The reference stand enables it because it has one drive.
403
697
  if (navView.value && navView.value !== 'trash') return true;
404
698
  if (!multiStorageRoot.value) return false;
405
699
  return !((currentPath.value ?? '').replace(/^\/+|\/+$/g, ''));
406
700
  });
407
701
 
702
+ /* surucu:d1-scope — WHERE THE FILTER ROW IS DRAWN, 2026-09-13.
703
+ *
704
+ * ⚠⚠ There is no longer a LIST of places. The row is drawn everywhere, and the
705
+ * only question left is which SHAPE it takes — and that is answered by what the
706
+ * rows are, not by which view you are in:
707
+ *
708
+ * rows are files → the whole row (Type · People · Modified · Size · find ·
709
+ * sort · ⋮). A folder, the trash, Starred, Shared,
710
+ * Recent, a tag — "it is a listing like any other"
711
+ * (owner, on the tag view), and the four chips answer
712
+ * from fields those rows carry.
713
+ * rows are not → the name box alone. The drive list (each row is a
714
+ * storage) and Home (three blocks of cards).
715
+ *
716
+ * ⚠ It used to be `!atVirtualRoot`, which is a different question altogether:
717
+ * that flag answers "is there a backend folder here to create in / upload to".
718
+ * Borrowing it cost the row its place in six views at once, including the two
719
+ * the owner asked for it back in ("root folder'da filtre barı kalsın … orada
720
+ * adam isterse storage ismi aratabilir" and "Home sayfasında da filtreleme
721
+ * barını getirelim").
722
+ */
723
+ /** Home's body is three blocks of cards, not a listing — name box only. The
724
+ * drive root reaches the same shape through the pane's own `atVirtualRoot`,
725
+ * because a pane knows when it is showing drives and both panes can be. */
726
+ const filterRowMode = computed<'full' | 'find'>(() =>
727
+ navView.value === 'home' ? 'find' : 'full',
728
+ );
729
+
408
730
  function goUp() {
409
731
  // Leaving the trash view returns to the storage it was opened from, not the
410
732
  // global storage-list root.
@@ -428,7 +750,45 @@ function goUp() {
428
750
  void load(parent);
429
751
  }
430
752
 
431
- const selection = useSelection(() => files.value);
753
+ /**
754
+ * gorunum:v1 — what the ACTIVE view is showing, in the order it shows it.
755
+ *
756
+ * A shift-range is arithmetic over a list, and this used to run it over
757
+ * `files` — the backend's answer — while the user was looking at a sorted or
758
+ * folder-hoisted one. Measured on a seeded storage: shift-clicking the first
759
+ * and fourth visible rows selected eight, because the folder the view had
760
+ * lifted to the top still sat last in `files`. The view now says what it drew
761
+ * (`display-order`) and the range is computed over that; `files` remains the
762
+ * fallback for the moment before the first paint and for surfaces that publish
763
+ * nothing.
764
+ */
765
+ const displayOrder = ref<FileNode[]>([]);
766
+
767
+ /**
768
+ * gorunum:v1 — where the previewed file sits in what the user is looking at.
769
+ *
770
+ * Counted over the DISPLAYED order, not over `files`: the viewer's "3 of 9"
771
+ * and its chevrons have to agree with the listing behind them, and that order
772
+ * is the view's, not the backend's. Directories are skipped — the viewer
773
+ * cannot open one, so counting them would promise a step that does nothing.
774
+ */
775
+ const previewables = computed<FileNode[]>(() =>
776
+ (displayOrder.value.length ? displayOrder.value : files.value).filter((n) => n.type !== 'dir'),
777
+ );
778
+ const previewPosition = computed(() => {
779
+ const list = previewables.value;
780
+ const path = previewTarget.value?.path;
781
+ const i = path ? list.findIndex((n) => n.path === path) : -1;
782
+ return { index: i === -1 ? 0 : i + 1, total: i === -1 ? 0 : list.length };
783
+ });
784
+ function onPreviewNav(delta: number) {
785
+ const list = previewables.value;
786
+ const i = list.findIndex((n) => n.path === previewTarget.value?.path);
787
+ if (i === -1) return;
788
+ const next = list[i + delta];
789
+ if (next) previewTarget.value = next;
790
+ }
791
+ const selection = useSelection(() => (displayOrder.value.length ? displayOrder.value : files.value));
432
792
  watch(
433
793
  () => [...selection.selected.value],
434
794
  () => {
@@ -446,6 +806,15 @@ watch(
446
806
  const clipboard = ref<ClipboardState>({ mode: null, items: [], sourcePath: null });
447
807
 
448
808
  const capabilitiesData = ref<Capabilities | null>(null);
809
+ /* zaman:z3 — the two clock tiers only an explorer instance can know: the zone
810
+ * its host configured, and the account behind its credential when that
811
+ * credential is a person's. Ranked in lib/timezone, never here. */
812
+ const showTimeZone = ref(false);
813
+ useExplorerTimeZone({
814
+ config: () => props.config,
815
+ capabilities: capabilitiesData,
816
+ fetchMe: () => api.jsonFetch(`${connectionsBase(props.config)}/api/auth/me`),
817
+ });
449
818
  // Longest life a new share link may be given (server setting, days; 0 = no
450
819
  // ceiling). Both share dialogs derive their expiry choices from it.
451
820
  const shareMaxTtlDays = computed(() => capabilitiesData.value?.share_max_ttl_days ?? 0);
@@ -598,6 +967,27 @@ function openTagPickerFor(n: FileNode) {
598
967
  * notices. Drop it and re-ask; if a tag view is on screen, refresh it too —
599
968
  * removing a file's tag has to remove it from the listing that is named after
600
969
  * that tag. */
970
+ /**
971
+ * etiket:t1 — "show me everything tagged this", from a FILE.
972
+ *
973
+ * ⚠⚠ The missing half of the tag feature, reported 2026-09-13 ("taglediğim
974
+ * dosya klasör tag'ine gitmiyor"): the view existed, the panel's Tags section
975
+ * listed every tag, and from a file's own chip there was no way in — you had to
976
+ * read the word off the chip and go find it again in the panel.
977
+ *
978
+ * ⚠ It is `loadTagView`, not a variant of it. The panel's Tags section, a
979
+ * restored tab, a pasted `#.tag~invoices` and now a chip all land in the one
980
+ * loader, so there is one definition of what a tag view IS.
981
+ *
982
+ * ⚠ The modal is closed on the way. It is opened over a listing to EDIT tags;
983
+ * once the chip has navigated, leaving it up means a dialog about one file
984
+ * covering the view of all the others that share its tag.
985
+ */
986
+ function openTagView(tag: string) {
987
+ showTagPicker.value = false;
988
+ void loadTagView(tag);
989
+ }
990
+
601
991
  function onNodeTagsChanged() {
602
992
  invalidateTagCache();
603
993
  void loadNavTags(true);
@@ -651,29 +1041,48 @@ const effectiveConvertUrl = computed<string | null>(
651
1041
  () => props.config.convertBase || capabilitiesData.value?.convert_url || null,
652
1042
  );
653
1043
 
1044
+ /* belge:n1 — what the SERVER can create, crossed with what WE could open.
1045
+ * `null` (a backend older than the feature) hides the entry entirely. */
1046
+ const newDocTypes = computed(() => capabilitiesData.value?.newdoc_types ?? null);
1047
+ const canNewDocument = computed(() => {
1048
+ const list = newDocTypes.value;
1049
+ if (!list || list.length === 0) return false;
1050
+ return list.some((ty) =>
1051
+ ty.requires === 'onlyoffice'
1052
+ ? !!effectiveOnlyOfficeBase.value
1053
+ : ty.requires === 'drawio'
1054
+ ? !!effectiveDrawioUrl.value
1055
+ : true,
1056
+ );
1057
+ });
1058
+
654
1059
  // Upload
655
1060
  const uploadJobs = ref<UploadJob[]>([]);
656
1061
  const fileInputEl = ref<HTMLInputElement | null>(null);
657
1062
 
658
1063
  // Modals
659
1064
  const showNewFolder = ref(false);
1065
+ const showNewDocument = ref(false); /* belge:n1 */
660
1066
  const showRename = ref(false);
661
1067
  const showDelete = ref(false);
662
- const showShare = ref(false);
663
1068
  const showPreview = ref(false);
664
1069
  const renameTarget = ref<FileNode | null>(null);
665
1070
  /* ui-fix — does the open rename/delete/new-folder modal belong to the side
666
1071
  * pane? (the menu is identical to the main pane's; this routes the mutation
667
1072
  * to the right one.) */
668
1073
  const mutationInPane = ref(false);
669
- const shareTarget = ref<FileNode | null>(null);
670
- const activeShare = ref<(ShareInfo & { url: string; filename?: string }) | null>(null);
671
1074
  const previewTarget = ref<FileNode | null>(null);
672
1075
  const previewMode = ref<'edit' | 'view'>('edit');
673
1076
  const showConvert = ref(false);
674
1077
  const convertTarget = ref<FileNode | null>(null);
675
1078
  const showPerm = ref(false);
676
1079
  const permTarget = ref<FileNode | null>(null);
1080
+ /* tasi:m1 — "Move to…" / "Copy to…" ask the SAME dialog where; only the mode
1081
+ * differs, so there is one piece of state and not two dialogs. */
1082
+ const showDestPicker = ref(false);
1083
+ const destPickerMode = ref<'move' | 'copy'>('move');
1084
+ const destPickerTargets = ref<FileNode[]>([]);
1085
+ const destPickerBusy = ref(false);
677
1086
 
678
1087
  /* === koru:k1 — inspector (details) panel ===
679
1088
  * Open/closed preference persists under `filex.inspector`; the panel itself
@@ -717,19 +1126,28 @@ function closeInspector() {
717
1126
  * existing UI keeps its width when somebody does not want it (GitHub #14).
718
1127
  *
719
1128
  * The panel is NOT gated on role or profile: administrators get it too, and
720
- * `uiProfile` only changes the rest of the chrome. Gating it would be exactly
721
- * the "one behaviour on one surface" split this shared package exists to
722
- * prevent. */
723
- const uiProfile = computed(() => props.config.uiProfile ?? 'standard');
1129
+ * so is everything else the header and the panel draw. Gating any of it would
1130
+ * be exactly the "one behaviour on one surface" split this shared package
1131
+ * exists to prevent — which is precisely what a per-role profile in the web
1132
+ * app turned out to be. */
1133
+ const uiProfile = computed(() => resolveUiProfile(props.config.uiProfile));
724
1134
  /**
725
- * `drive` answers TRUE here. It is a superset of `simple`, so every question
726
- * `simple` already answers ("one pane?", "no tab strip?", "list and grid
727
- * only?") must keep the same answer under it asking `=== 'simple'` in those
728
- * places is how the drive profile would silently grow a split pane the day
729
- * somebody adds a fourth condition. `driveShell` is only for what `drive` adds
730
- * on TOP.
1135
+ * Is this the REDUCED explorer one pane, one folder, list/grid only?
1136
+ *
1137
+ * ⚠⚠ This is the only question `uiProfile` still answers, and it is a question
1138
+ * about REDUCTION, never about LOOK. The shell (the header with its one search
1139
+ * field, the filter row, "+ New", the Folders/Files sections, the info panel's
1140
+ * tabs, the storage line) is what filex IS — it is not a profile anything can
1141
+ * be put into, so nothing below reads a profile to decide whether to draw it.
1142
+ * The day that distinction blurs again, the profiles become two products with
1143
+ * one name, which is exactly what this pass undid.
1144
+ *
1145
+ * ⚠ There are TWO values, and the rule for everything else — a typo, or the
1146
+ * profile removed after v0.40.0 — lives in `lib/uiProfile` together with the
1147
+ * argument for it. Nothing here branches on a retired name; this file asks one
1148
+ * question of a value that has already been resolved.
731
1149
  */
732
- const simpleUi = computed(() => uiProfile.value === 'simple' || uiProfile.value === 'drive');
1150
+ const simpleUi = computed(() => uiProfile.value === 'simple');
733
1151
 
734
1152
  /**
735
1153
  * ⚠ `showInfoPanel` is documented public API ("whether the info panel toggle
@@ -740,8 +1158,13 @@ const simpleUi = computed(() => uiProfile.value === 'simple' || uiProfile.value
740
1158
  * reachable from the context menu, which is what the option says.
741
1159
  */
742
1160
  const infoPanelToggle = computed(() => props.config.showInfoPanel !== false);
743
- /* === surucu:d1 — the Drive shell (GitHub #14, the reporter's mockups) ===== */
744
- const driveShell = computed(() => uiProfile.value === 'drive');
1161
+ /* === surucu:d1 — the shell (GitHub #14, the reporter's mockups) ===========
1162
+ * There is no `driveShell` computed any more, and its absence is the point:
1163
+ * the filter row, "+ New", the info-panel tabs and the storage line are drawn
1164
+ * because this is filex, not because a caller passed a string. Grep for
1165
+ * `surucu:d1` to find them; every one of them is now unconditional or gated on
1166
+ * something real (a folder to filter, a person to have a quota).
1167
+ */
745
1168
 
746
1169
  const SIDENAV_LS_KEY = 'filex.sidenav';
747
1170
  const sideNavExpanded = ref<boolean>(
@@ -920,12 +1343,70 @@ watchEffect(() => {
920
1343
  * folder look empty, and the reason is off-screen the moment you scroll.
921
1344
  */
922
1345
  const driveFilters = ref<DriveFilters>({ ...EMPTY_FILTERS });
923
- const filtersOn = computed(() => driveShell.value && filtersActive(driveFilters.value));
924
- /** What the views render. Identical reference to `files` when nothing is set. */
925
- const displayFiles = computed<FileNode[]>(() =>
926
- driveShell.value ? applyFilters(files.value, driveFilters.value) : files.value,
927
- );
1346
+ /* gorunum:v1-advsearch declared HERE, beside the row's own state, and not
1347
+ down with the rest of the dialog's wiring: `filtersOn` and `displayFiles`
1348
+ read them, and a ref declared after a computed that touches it is the exact
1349
+ "Cannot access X before initialization" this file was taken down by once
1350
+ before (see the navVisible watcher note in onMounted). */
1351
+ const advFilters = ref<DriveFilters | null>(null);
1352
+ const advScope = ref<AdvScope>('name');
1353
+ /**
1354
+ * surucu:d1-sort — WHERE THE ROWS IN HAND GOT THEIR ORDER, and the only place
1355
+ * in the bundle that knows. `files` is a search answer exactly when
1356
+ * `searchQuery` is set (`load()` picks `action=search` / `/api/files/search`
1357
+ * off that same ref), and a search answer is RANKED: the backend scores every
1358
+ * candidate (`internal/search/scorer.go`, ported from VS Code's Quick Open)
1359
+ * and returns best-first.
1360
+ *
1361
+ * ⚠⚠ Owner's ruling, 2026-09-12, verbatim (translated from Turkish): "the
1362
+ * filter in advanced search should belong to it alone. The other, ordinary
1363
+ * search and the ⌘K side must stay in relevance order." Measured the next day
1364
+ * on qldemo with the query `s`: the server ranked `Documents/server.ts` first
1365
+ * and the list drew it FIFTEENTH of seventeen, because the active sort key was
1366
+ * applied to everything the listing shows. Nothing looked broken — the grid
1367
+ * and the list agreed with each other and every test was green — because
1368
+ * re-alphabetising a ranked list is indistinguishable from sorting a folder.
1369
+ *
1370
+ * ⚠ Both scopes, one answer: the advanced dialog's content search lands in
1371
+ * `files` through this same ref, so it is covered without a second rule. The
1372
+ * ⌘K palette never needed one — it renders its own hits straight from
1373
+ * `paletteGlobalSearch` and reaches for no comparator (verified, not assumed).
1374
+ *
1375
+ * ⚠ Declared HERE, above `displayFiles`, for the reason the `advFilters` note
1376
+ * above gives: a ref/computed declared after the computed that reads it is the
1377
+ * "Cannot access X before initialization" this file was taken down by once.
1378
+ *
1379
+ * ⚠ And NOT a module-level flag in `lib/sortOrder`: the split view's secondary
1380
+ * pane only ever lists (`SecondaryPane.loadPane` calls `index`), so a global
1381
+ * "we are searching" would silently unsort the pane that is not.
1382
+ */
1383
+ const listingOrder = computed<ListingOrder>(() => (searchQuery.value ? 'relevance' : 'sort'));
1384
+ /* pane:p1 — `filtersOn` and `displayFiles` USED TO LIVE HERE, and they are the
1385
+ * clearest example of what this refactor is for: they compose the filter row's
1386
+ * narrowing, the advanced dialog's narrowing and the sort into the rows a
1387
+ * listing draws — a PANE's job, done once in the host, which is why the split
1388
+ * view's right-hand half had no filter row for two months and would have had
1389
+ * to grow a second copy of this to get one. They are `FilePane`'s
1390
+ * `displayFiles` / `filtersOn` now, and every pane has them.
1391
+ *
1392
+ * ⚠ `advFilters` stays here, and it is not an exception: it belongs to the
1393
+ * advanced SEARCH, which is the window's (one search field, one dialog, one
1394
+ * set of results). It is handed to the main pane as `extra-filters`, which is
1395
+ * a narrowing composed AFTER the pane's own chips rather than instead of them.
1396
+ * ⚠ `listingOrder` stays here for the same reason and is passed as `order`:
1397
+ * whether the rows in hand were RANKED by the server is a fact about the
1398
+ * answer the host fetched, not a preference the pane holds. The split pane
1399
+ * only ever lists a folder, so it is never in relevance mode — which is
1400
+ * exactly why a global "we are searching" flag would have been wrong.
1401
+ */
1402
+
1403
+ /* gorunum:v2-topbar / pane:p1 — the breadcrumb's "Subfolders" chevron used to
1404
+ * be fed from here, which is why only the left-hand pane had one: the host
1405
+ * knows ONE folder's listing and there are two panes. It is derived inside
1406
+ * `FilePane` now, from the rows that pane is holding. */
1407
+
928
1408
  function clearDriveFilters() {
1409
+ advFilters.value = null /* gorunum:v1-advsearch — the escape hatch clears BOTH */;
929
1410
  setDriveFilters({ ...EMPTY_FILTERS });
930
1411
  }
931
1412
  function setDriveFilters(v: DriveFilters) {
@@ -938,9 +1419,169 @@ watch(
938
1419
  () => `${currentPath.value}|${navView.value}`,
939
1420
  () => {
940
1421
  if (filtersActive(driveFilters.value)) driveFilters.value = { ...EMPTY_FILTERS };
1422
+ /* gorunum:v1-advsearch — the advanced filters belong to the search that
1423
+ set them, so they survive the rebase a search causes (a search moves
1424
+ `currentPath` to the storage root, which is what fires this watcher) and
1425
+ are dropped the moment there is no search left to belong to. */
1426
+ if (!searchQuery.value) advFilters.value = null;
941
1427
  },
942
1428
  );
943
1429
 
1430
+ /* === gorunum:v1-advsearch — the Advanced search dialog ===================
1431
+ *
1432
+ * One results surface: whatever the dialog asks for lands in `files` through
1433
+ * the same `load()` a toolbar search lands in. There is no second list, no
1434
+ * "search results" page and no separate empty state — the dialog composes a
1435
+ * query, the explorer runs it, and the rows arrive where rows always arrive.
1436
+ *
1437
+ * Two things the dialog cannot do itself, and they live here because this is
1438
+ * where the API client is:
1439
+ *
1440
+ * 1. **Scope routing.** `name` is served by the manager's
1441
+ * `?action=search&filter=…`, which is what the toolbar already uses and
1442
+ * what returns adapter-qualified rows. `content`/`all` exist ONLY on
1443
+ * `/api/files/search` (the manager's search action hardcodes
1444
+ * `search.ScopeName`), whose rows are raw node rows — so they are mapped
1445
+ * onto the listing shape below.
1446
+ * 2. **The live count**, which is a real query. See `advSearchCount`.
1447
+ */
1448
+ const showAdvSearch = ref(false);
1449
+ /** The folder the dialog was opened from, frozen for its lifetime. */
1450
+ const advPathBase = ref('');
1451
+
1452
+ /**
1453
+ * Whether the content scopes may be offered at all.
1454
+ *
1455
+ * ⚠ This USED to be a capability check: `/api/files/search` answered with a
1456
+ * `storage_id` and no storage name, the explorer has no id→name map, and a row
1457
+ * wearing the wrong drive's name is worse than a scope we did not offer — so
1458
+ * the tabs were gated to single-storage installs, where the guess could not be
1459
+ * wrong. `handlers/search.go` now labels every hit with its drive's name (and
1460
+ * its owner) in `describeHits`, so there is nothing left to guess and the
1461
+ * scopes are offered everywhere. Kept as a computed rather than deleted: the
1462
+ * prop is public API, and an embedder pointed at an older backend still gets
1463
+ * hits with no `storage` — which `advHitToNode` falls back for, row by row.
1464
+ */
1465
+ const advContentAvailable = computed(() => true);
1466
+
1467
+ /** How many hits we ask the content endpoint for. The manager's search action
1468
+ * uses 250 internally; matching it keeps the two scopes comparable. */
1469
+ const ADV_CONTENT_LIMIT = 250;
1470
+
1471
+ /** Map one raw search hit onto the listing shape.
1472
+ *
1473
+ * ⚠ The drive comes from the HIT (`describeHits` puts it there), not from the
1474
+ * pane we happen to be standing in — a content search spans storages, so
1475
+ * `adapter.value` is only the right answer by accident. It stays as the
1476
+ * fallback for a backend older than that field. */
1477
+ function advHitToNode(h: GlobalSearchHit, storageName: string): FileNode {
1478
+ const rel = String(h.path ?? '').replace(/^\/+/, '');
1479
+ const drive = typeof h.storage === 'string' && h.storage ? h.storage : storageName;
1480
+ const name = String(h.name ?? rel.split('/').pop() ?? '');
1481
+ const dot = name.lastIndexOf('.');
1482
+ const mtime = typeof h.backend_mtime === 'string' ? h.backend_mtime : h.updated_at;
1483
+ const ms = typeof mtime === 'string' ? Date.parse(mtime) : NaN;
1484
+ return {
1485
+ id: typeof h.id === 'number' ? h.id : undefined,
1486
+ path: drive ? `${drive}://${rel}` : rel,
1487
+ basename: name,
1488
+ relativePath: rel,
1489
+ type: h.type === 'dir' ? 'dir' : 'file',
1490
+ extension: dot > 0 ? name.slice(dot + 1).toLowerCase() : '',
1491
+ size: typeof h.size === 'number' ? h.size : 0,
1492
+ // ⚠ Left UNSET when the row carries no parseable timestamp rather than
1493
+ // defaulted to 0 or to now: `matchesModified` treats a missing timestamp
1494
+ // as "unknown" and drops the row from a date filter, which is the honest
1495
+ // answer. Stamping it with `Date.now()` would file every such file under
1496
+ // "Today".
1497
+ last_modified: Number.isNaN(ms) ? undefined : ms,
1498
+ mime_type: typeof h.mime === 'string' ? h.mime : '',
1499
+ /* The content snippet the hit came with, so a content match can show why
1500
+ it matched. Undefined on name-only hits, exactly as the backend sends. */
1501
+ snippet: typeof h.snippet === 'string' && h.snippet ? h.snippet : undefined,
1502
+ /* Owner, so the People filter and the Owner column mean the same thing in a
1503
+ content result as they do in a folder listing. Undefined rather than
1504
+ guessed when the backend does not send it. */
1505
+ owner_id: typeof h.owner_id === 'number' ? h.owner_id : undefined,
1506
+ owner_name: typeof h.owner_name === 'string' ? h.owner_name : undefined,
1507
+ owner_self: h.owner_self === true ? true : undefined,
1508
+ };
1509
+ }
1510
+
1511
+ /** Run one advanced search and hand back the rows, unfiltered. */
1512
+ async function advFetchRows(scope: AdvScope, query: string, target: string): Promise<FileNode[]> {
1513
+ if (scope === 'name') {
1514
+ const resp = await api.search(target, query);
1515
+ return filterListing(resp.files);
1516
+ }
1517
+ const hits = await api.globalSearch(query, { limit: ADV_CONTENT_LIMIT, scope });
1518
+ const storageName = adapter.value || (props.config.storages ?? [])[0]?.name || '';
1519
+ return filterListing(hits.map((h) => advHitToNode(h, storageName)));
1520
+ }
1521
+
1522
+ /** The target `load()` would use for the current position. */
1523
+ function advTarget(): string {
1524
+ const requested = currentPath.value ?? '';
1525
+ return multiStorageRoot.value ? virtualToWire(requested) : qualify(requested);
1526
+ }
1527
+
1528
+ /**
1529
+ * The dialog's live count — a REAL query, not an estimate.
1530
+ *
1531
+ * ⚠ It costs exactly what pressing Search costs: the same request, the same
1532
+ * rows, the same client-side narrowing. There is no cheaper way to answer it —
1533
+ * neither endpoint has a count mode — so the dialog prints that the number is
1534
+ * produced by running the search rather than letting it look free.
1535
+ *
1536
+ * `capped` is the other half of the honesty: when the server returned as many
1537
+ * hits as it was allowed to, the client-side filters narrowed a window and the
1538
+ * number describes the rows that came back, not the storage.
1539
+ */
1540
+ async function advSearchCount(req: AdvSearchRequest): Promise<AdvCountResult> {
1541
+ const query = advQueryString(req);
1542
+ const rows = await advFetchRows(req.scope, query, advTarget());
1543
+ const limit = req.scope === 'name' ? 250 : ADV_CONTENT_LIMIT;
1544
+ return {
1545
+ count: applyFilters(rows, req.filters).length,
1546
+ capped: advSearchTruncated(rows.length, limit),
1547
+ };
1548
+ }
1549
+
1550
+ function openAdvancedSearch(seed: string) {
1551
+ advPathBase.value = qualify(currentPath.value ?? '') || '';
1552
+ advSearchSeed.value = seed;
1553
+ showAdvSearch.value = true;
1554
+ }
1555
+ const advSearchSeed = ref('');
1556
+
1557
+ function applyAdvancedSearch(req: AdvSearchRequest) {
1558
+ showAdvSearch.value = false;
1559
+ advScope.value = req.scope;
1560
+ advFilters.value = filtersActive(req.filters) ? { ...req.filters } : null;
1561
+ const q = advQueryString(req);
1562
+ // Same text as the box already holds → the `searchQuery` watcher will not
1563
+ // fire, so the reload (which the new scope/filters need) is issued here.
1564
+ if (q === searchQuery.value) void load();
1565
+ else searchQuery.value = q;
1566
+ }
1567
+
1568
+ /** Typing in the toolbar field is a plain search again — it replaces whatever
1569
+ * the dialog set rather than silently inheriting filters the user cannot see
1570
+ * from a box that shows only words. */
1571
+ function onToolbarSearch(v: string) {
1572
+ advFilters.value = null;
1573
+ advScope.value = 'name';
1574
+ /* gorunum:v3-shell — Home has no listing behind it, and `searchQuery` is
1575
+ watched by `load()`. Setting it here re-entered loadNavView('home') on
1576
+ every keystroke — two fetches per pause, for a narrowing that could not
1577
+ appear anywhere. The words are not lost: Enter hands them to the command
1578
+ palette (Toolbar `searchEscalates`), which is the "everywhere" search the
1579
+ field's own ⌘K chip advertises. */
1580
+ if (navView.value === 'home') return;
1581
+ searchQuery.value = v;
1582
+ }
1583
+ /* === /gorunum:v1-advsearch === */
1584
+
944
1585
  /**
945
1586
  * surucu:d1 — what the header field says it will search: the folder you are
946
1587
  * standing in, by name. At a storage root that is the storage; in a panel view
@@ -948,6 +1589,10 @@ watch(
948
1589
  * searches what is on screen.
949
1590
  */
950
1591
  const driveScopeLabel = computed(() => {
1592
+ /* gorunum:v3-shell — Home is not a place to search IN. Its three blocks are
1593
+ an overview of everything, so "Search in Home" would name a scope that
1594
+ does not exist; the placeholder falls back to the everywhere wording. */
1595
+ if (navView.value === 'home') return '';
951
1596
  if (navView.value === 'tag') return navTag.value;
952
1597
  if (navView.value) return t(`sidenav.${navView.value}`);
953
1598
  const rel = currentPath.value.replace(/\/+$/, '');
@@ -996,15 +1641,15 @@ function onInspectorShareCreated(payload: { path: string; url: string }) {
996
1641
  }
997
1642
 
998
1643
  /* === surucu:d1 — the storage line under the navigation ==================
999
- * Fetched once per mount, and ONLY in the drive shell: no other profile draws
1000
- * it, and an explorer that has drawn this panel for a year should not start
1001
- * making a request it has no use for. `quotaMe()` answers null for a server
1002
- * without the route or a caller without a person behind it, and null renders
1003
- * nothing at all.
1644
+ * Fetched once per mount. The gate is `identitySurfaces` and nothing else: a
1645
+ * quota is one PERSON's ceiling, so an app token has nobody to have one, but
1646
+ * every surface that has a person behind it draws the same line this is the
1647
+ * shell, not a profile. `quotaMe()` answers null for a server without the
1648
+ * route, and null renders nothing at all.
1004
1649
  */
1005
1650
  const quotaSnapshot = ref<{ used: number; total: number; unlimited: boolean } | null>(null);
1006
1651
  async function loadQuota() {
1007
- if (!driveShell.value || !identitySurfaces.value) {
1652
+ if (!identitySurfaces.value) {
1008
1653
  quotaSnapshot.value = null;
1009
1654
  return;
1010
1655
  }
@@ -1040,6 +1685,15 @@ async function loadQuota() {
1040
1685
  * safe fallback is the single-storage case — guessing in a multi-storage
1041
1686
  * install sends the user to a path in somebody else's drive.
1042
1687
  */
1688
+ /** tablo:t1 — an RFC3339 stamp from a node row as unix ms, or undefined. A
1689
+ * string we cannot parse is left undefined rather than turned into `NaN`,
1690
+ * which would print as "Invalid Date" and sort unpredictably. */
1691
+ function rowMillis(v: unknown): number | undefined {
1692
+ if (typeof v !== 'string' || !v) return undefined;
1693
+ const ms = Date.parse(v);
1694
+ return Number.isFinite(ms) ? ms : undefined;
1695
+ }
1696
+
1043
1697
  function nodeRowToFileNode(row: Record<string, unknown>): FileNode | null {
1044
1698
  const rel = String(row?.path ?? '').replace(/^\/+/, '');
1045
1699
  if (!rel) return null;
@@ -1058,6 +1712,17 @@ function nodeRowToFileNode(row: Record<string, unknown>): FileNode | null {
1058
1712
  return {
1059
1713
  type: isDir ? 'dir' : 'file',
1060
1714
  id,
1715
+ /* tablo:t1 — ⚠⚠ THE DATE. A node row carries `backend_mtime` (what the
1716
+ storage says) and `db_mtime` (what our last scan recorded) as RFC3339
1717
+ strings; `FileNode.last_modified` is unix MILLISECONDS. Nothing mapped
1718
+ between the two, so every row from Recent, Starred and a tag view
1719
+ arrived with no date at all — measured on Recent: eleven rows, eleven em
1720
+ dashes in the Modified column, and a Modified column header you could
1721
+ click that then sorted nothing. It also made the date grouping this view
1722
+ is supposed to show impossible, because every row fell in the "No date"
1723
+ bucket. Storage first: `backend_mtime` is the file's own truth and
1724
+ `db_mtime` only says when we last looked at it. */
1725
+ last_modified: rowMillis(row.backend_mtime) ?? rowMillis(row.db_mtime),
1061
1726
  path: storageName ? `${storageName}://${rel}` : rel,
1062
1727
  basename: name,
1063
1728
  extension: isDir
@@ -1107,15 +1772,79 @@ async function fetchNavRows(kind: 'recent' | 'starred' | 'shared'): Promise<File
1107
1772
  return rows.map(nodeRowToFileNode).filter((n): n is FileNode => n !== null);
1108
1773
  }
1109
1774
 
1775
+ /* === gorunum:v3-shell — the Home view's own state ========================
1776
+ * Two lists and a flag, and nothing else: the storages are already
1777
+ * `config.storages` (the host's list, kept current by its own Refresh) and the
1778
+ * cards come from GridView, so Home adds no third source of truth about what
1779
+ * exists — it only asks the two per-user endpoints the panel's Recent and
1780
+ * Starred rows already ask.
1781
+ *
1782
+ * ⚠ `files` stays EMPTY while Home is open. Home is not a listing: it renders
1783
+ * its own sections, and putting its rows in `files` would hand the selection,
1784
+ * the inspector, the keyboard range and every `files.length` in this file a
1785
+ * list nobody is standing in.
1786
+ */
1787
+ const homeRecent = ref<FileNode[]>([]);
1788
+ const homeStarred = ref<FileNode[]>([]);
1789
+ const homeLoading = ref(false);
1790
+
1791
+ /**
1792
+ * The storages Home draws.
1793
+ *
1794
+ * ⚠ Straight from `config.storages`, NOT a second fetch. The host already
1795
+ * decided which drives this caller may see (RBAC on the server, then
1796
+ * `fetchVisibleStorages` in our own app) and the navigation panel two hundred
1797
+ * pixels to the left is rendering that same array — a Home that asked for its
1798
+ * own copy could show a drive the panel beside it hides.
1799
+ */
1800
+ const homeStorages = computed(() => props.config.storages ?? []);
1801
+
1802
+ async function loadHome() {
1803
+ homeLoading.value = true;
1804
+ try {
1805
+ // ⚠ Both at once and neither fatal on its own: a server without the
1806
+ // starred endpoint must still be able to show somebody their recents.
1807
+ const [r, st] = await Promise.all([
1808
+ fetchNavRows('recent').catch(() => [] as FileNode[]),
1809
+ fetchNavRows('starred').catch(() => [] as FileNode[]),
1810
+ ]);
1811
+ homeRecent.value = r;
1812
+ homeStarred.value = st;
1813
+ } finally {
1814
+ homeLoading.value = false;
1815
+ }
1816
+ }
1817
+
1110
1818
  /** Open one of the panel views in the main pane. */
1111
1819
  async function loadNavView(kind: Exclude<NavView, ''>) {
1112
1820
  closeNavDrawer();
1821
+ if (kind === 'home') {
1822
+ // ⚠ The mode is set BEFORE the fetch, unlike the listing views below: Home
1823
+ // renders its own sections with their own loading line, so there is
1824
+ // nothing to hold back — and setting it afterwards would leave the
1825
+ // previous folder's files on screen under the panel row that already reads
1826
+ // as selected.
1827
+ if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
1828
+ navView.value = 'home';
1829
+ navTag.value = '';
1830
+ trashMode.value = false;
1831
+ e2eRoot.value = '';
1832
+ selection.clear();
1833
+ files.value = [];
1834
+ dirname.value = NAV_VIEW_DIRNAME.home;
1835
+ currentPath.value = NAV_VIEW_DIRNAME.home;
1836
+ adapter.value = '';
1837
+ await loadHome();
1838
+ return;
1839
+ }
1113
1840
  if (kind === 'tag') {
1114
1841
  // The tag view needs a name; the panel calls loadTagView directly.
1115
1842
  if (navTag.value) await loadTagView(navTag.value);
1116
1843
  return;
1117
1844
  }
1118
1845
  if (kind === 'trash') {
1846
+ void probeTrashPolicy(); /* tablo:t1 — in parallel: the banner is above the
1847
+ listing and must not wait behind it */
1119
1848
  await loadTrash();
1120
1849
  // ⚠ After loadTrash, not before: loadTrash goes through load()-adjacent
1121
1850
  // state and the mode has to be the last word, or the panel row for Trash
@@ -1224,6 +1953,25 @@ async function loadNavTags(force = false) {
1224
1953
 
1225
1954
  /* === /etiket:t1 === */
1226
1955
 
1956
+ /**
1957
+ * gorunum:v3-shell — what the panel's first group does.
1958
+ *
1959
+ * ⚠ "My files" is answered with an ordinary navigation, not with a view. It
1960
+ * opens the ROOT — the storage list in a multi-storage install, the storage
1961
+ * root in a single-storage one, and the confined floor inside a `rootPath`
1962
+ * embed (load() clamps it, so the row cannot be used to climb out of a
1963
+ * confined explorer). `load('')` also clears `navView`, which is what takes
1964
+ * the panel's highlight off whichever view you were in.
1965
+ */
1966
+ function openNavDest(dest: NavDest) {
1967
+ if (dest === 'myfiles') {
1968
+ closeNavDrawer();
1969
+ void load('');
1970
+ return;
1971
+ }
1972
+ void loadNavView(dest);
1973
+ }
1974
+
1227
1975
  /** Panel to a storage root. */
1228
1976
  function openNavStorage(name: string) {
1229
1977
  closeNavDrawer();
@@ -1251,17 +1999,31 @@ async function loadSharedStorages() {
1251
1999
  }
1252
2000
  }
1253
2001
  /* === /gezinti:g1 === */
1254
- // Folder summary label for the no-selection state.
1255
- const inspectorDirLabel = computed(() => {
1256
- if (trashMode.value) return t('node.trash');
1257
- const p = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
1258
- if (!p) return adapter.value || t('breadcrumb.root');
2002
+ /**
2003
+ * What a user path READS AS — the folder's own name, or the view's.
2004
+ *
2005
+ * Takes the path as an argument rather than reading `currentPath`, because
2006
+ * there are two panes and the details panel follows whichever one has the
2007
+ * keyboard (see `inspectorDirLabel`).
2008
+ *
2009
+ * ⚠ The `trashMode` special case it used to open with is gone, and that is a
2010
+ * removal, not an omission: the trash view parks `.trash` in `currentPath`, and
2011
+ * `virtualSegmentLabel('.trash')` is `t('node.trash')` — the same string, by
2012
+ * the same route as every other view. One of the two was going to be forgotten
2013
+ * the next time a view was added; it is the one that could be.
2014
+ */
2015
+ function folderLabelOf(path: string): string {
2016
+ const p = (path ?? '').replace(/^\/+|\/+$/g, '');
2017
+ /* No path = the top of the tree. In multi-storage that is the DRIVE LIST, so
2018
+ naming it after `adapter` — whichever storage was loaded last — would head
2019
+ the panel with a drive the person is not looking at. */
2020
+ if (!p) return multiStorageRoot.value ? t('breadcrumb.root') : (adapter.value || t('breadcrumb.root'));
1259
2021
  const seg = p.split('/').pop() || p;
1260
2022
  /* etiket:t1 — a THIRD surface that renders a path segment, and it had the
1261
2023
  same hole the tab strip did: in a virtual view the details panel headed
1262
2024
  itself ".starred". Same shared resolver, so it cannot drift again. */
1263
2025
  return virtualSegmentLabel(seg, t) || seg;
1264
- });
2026
+ }
1265
2027
  function onInspectorManage(n: FileNode) {
1266
2028
  permTarget.value = n;
1267
2029
  showPerm.value = true;
@@ -1444,6 +2206,23 @@ function toggleHiddenFiles() {
1444
2206
  void splitPaneRef.value?.reload();
1445
2207
  }
1446
2208
 
2209
+ /**
2210
+ * gorunum:v2-topbar — what the Refresh control actually means.
2211
+ *
2212
+ * ⚠ ONE function behind BOTH doors (the header's button and the palette's
2213
+ * `refresh` command). They used to be two separate `() => load()` arrow
2214
+ * functions in the template, which is how one of them would have quietly kept
2215
+ * reloading only half of what the other does.
2216
+ *
2217
+ * The listing is ours; the storage list is the host's (`config.storages`), so
2218
+ * the host is told and re-answers it in its own time. Nothing here waits on
2219
+ * that: the folder is on screen again either way.
2220
+ */
2221
+ function refreshAll() {
2222
+ void load();
2223
+ emit('refresh');
2224
+ }
2225
+
1447
2226
  async function load(path?: string) {
1448
2227
  /* === etiket:t1 — a sentinel is a VIEW, not a folder ===================
1449
2228
  * A restored tab, a reload on `#.trash` / `#.starred` / `#.tag~invoices`,
@@ -1487,6 +2266,29 @@ async function load(path?: string) {
1487
2266
  else await loadNavView(asView.kind);
1488
2267
  return;
1489
2268
  }
2269
+ /* A different folder is a different listing, and a selection belongs to the
2270
+ * listing it was made in.
2271
+ *
2272
+ * ⚠⚠ Measured 2026-09-14 (v0.41.0 screenshot pass): double-clicking into an
2273
+ * encrypted folder left "1 selected" — cut, copy, DELETE — hanging over its
2274
+ * lock screen, and those actions were aimed at the folder the person was now
2275
+ * standing INSIDE. Two stale things carried it: the selection itself, and
2276
+ * `displayOrder`, the rows the view last drew. The lock screen (like the
2277
+ * not-found state) draws no view, so nothing replaced the parent's rows, and
2278
+ * `selection.nodes` kept resolving the double-clicked folder against them.
2279
+ * A plain folder hid the bug only because its own view re-published its rows
2280
+ * and the stale path stopped resolving — the selection was still there, and
2281
+ * stepping back up brought it back to life.
2282
+ *
2283
+ * ⚠ Only when the FOLDER changes. A reload of the same folder (a realtime
2284
+ * refresh, a mutation's re-render, a search rebased onto it) keeps what the
2285
+ * person picked. Same rule the split pane applies in `onPaneNavigate`. */
2286
+ const leaving = String(currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
2287
+ const arriveAt = (to: string) => {
2288
+ if (String(to ?? '').replace(/^\/+|\/+$/g, '') === leaving) return;
2289
+ selection.clear();
2290
+ displayOrder.value = [];
2291
+ };
1490
2292
  loading.value = true;
1491
2293
  // Any normal navigation exits trash mode (the trash view is entered only
1492
2294
  // by opening the virtual `.trash` row, which calls loadTrash()).
@@ -1515,6 +2317,7 @@ async function load(path?: string) {
1515
2317
  // bounded: the recursive call carries a non-empty path, so
1516
2318
  // virtualToWire() resolves and this branch is not re-entered.
1517
2319
  if (soleStorageName.value) return await load(soleStorageName.value);
2320
+ arriveAt('');
1518
2321
  currentPath.value = '';
1519
2322
  adapter.value = '';
1520
2323
  dirname.value = '';
@@ -1527,9 +2330,23 @@ async function load(path?: string) {
1527
2330
  ? virtualToWire(requested)
1528
2331
  : qualify(requested);
1529
2332
 
1530
- const resp = searchQuery.value
1531
- ? await api.search(target, searchQuery.value)
1532
- : await api.index(target);
2333
+ /* gorunum:v1-advsearch a content-scoped search cannot come from the
2334
+ manager's search action (it hardcodes `search.ScopeName`), so it is
2335
+ fetched from /api/files/search and projected onto the listing shape
2336
+ here. Everything downstream — the views, the selection, the inspector —
2337
+ sees ordinary rows, which is the point: one results surface. */
2338
+ const advContent = !!searchQuery.value && advScope.value !== 'name';
2339
+ const resp: ManagerResponse = advContent
2340
+ ? {
2341
+ adapter: adapter.value,
2342
+ storages: (props.config.storages ?? []).map((s) => s.name),
2343
+ dirname: dirname.value,
2344
+ read_only: false,
2345
+ files: await advFetchRows(advScope.value, searchQuery.value, target),
2346
+ }
2347
+ : searchQuery.value
2348
+ ? await api.search(target, searchQuery.value)
2349
+ : await api.index(target);
1533
2350
  adapter.value = resp.adapter;
1534
2351
  dirname.value = resp.dirname;
1535
2352
  dirPerm.value = (resp.perm as string) || '';
@@ -1537,6 +2354,10 @@ async function load(path?: string) {
1537
2354
  subtree; '' resets on every plain folder. Drives the lock screen. */
1538
2355
  e2eRoot.value = typeof resp.e2e_root === 'string' ? resp.e2e_root : '';
1539
2356
  /* /wiring:e2 */
2357
+ // currentPath is the user-facing form: `s3-test/example` in
2358
+ // multi-storage mode, the bare relative path otherwise.
2359
+ const arrived = multiStorageRoot.value ? wireToVirtual(resp.dirname) : stripAdapter(resp.dirname);
2360
+ arriveAt(arrived);
1540
2361
  files.value = filterListing(resp.files);
1541
2362
  // Inject virtual `.trash` entry at root only — shared helper so the
1542
2363
  // split-view secondary pane shows the exact same row (no row-offset).
@@ -1552,11 +2373,7 @@ async function load(path?: string) {
1552
2373
  ) {
1553
2374
  void hydrateTrashRowShared(files.value, resp.adapter, api);
1554
2375
  }
1555
- // currentPath is the user-facing form: `s3-test/example` in
1556
- // multi-storage mode, the bare relative path otherwise.
1557
- currentPath.value = multiStorageRoot.value
1558
- ? wireToVirtual(resp.dirname)
1559
- : stripAdapter(resp.dirname);
2376
+ currentPath.value = arrived;
1560
2377
  } catch (err) {
1561
2378
  const e = err instanceof Error ? err.message : String(err);
1562
2379
  const status = (err as { status?: number }).status;
@@ -1565,6 +2382,7 @@ async function load(path?: string) {
1565
2382
  // show the dedicated not-found state instead of a toast over a stale
1566
2383
  // listing that reads as "this folder is empty".
1567
2384
  notFoundPath.value = String(requested);
2385
+ arriveAt(String(requested));
1568
2386
  e2eRoot.value = ''; /* wiring:e2 — no lock screen left over on a dead link */
1569
2387
  files.value = [];
1570
2388
  emit('error', { message: e, context: { path } });
@@ -1607,6 +2425,106 @@ function leaveNotFound() {
1607
2425
  // Entered by opening the virtual `.trash` row. Each row keeps its node `id`
1608
2426
  // so restore can target it. Permanent delete is admin-only / auto-purge, so
1609
2427
  // the only mutation offered here is Restore.
2428
+ /* === tablo:t1 — the trash banner ======================================
2429
+ *
2430
+ * The reference build draws, above the listing: what the trash IS, and the one
2431
+ * irreversible action. We had neither — and the second half is a real
2432
+ * regression rather than a missing decoration, because the only "Empty trash"
2433
+ * in the whole repo is on the admin page `web/src/views/Trash.vue`, reached
2434
+ * from the admin panel's own sidebar. An end user, who never sees the admin
2435
+ * panel, had no way to empty their own trash at all.
2436
+ *
2437
+ * ⚠⚠ RETENTION IS A CLAIM, NOT A DECORATION. The reference says "30 days";
2438
+ * ours must say what THIS deployment actually does, and `trash.retention_days`
2439
+ * is a setting an operator changes. A banner stating the wrong number is worse
2440
+ * than no banner, because people act on it — they leave something in the
2441
+ * trash believing they have a month. So the number is asked for, and when the
2442
+ * answer does not come the wording drops the period instead of guessing one.
2443
+ *
2444
+ * ⚠⚠ ONE PROBE ANSWERS BOTH QUESTIONS. `GET /api/admin/protection` carries
2445
+ * the real retention and is refused to anyone who is not an operator — and
2446
+ * `POST /api/admin/trash/empty` is gated on exactly the same thing. So its
2447
+ * status code tells us the number AND whether this caller may empty anything,
2448
+ * without a UI-side role check. That matters here: this file's own rule is
2449
+ * that the BACKEND decides what a caller may see (see the note on
2450
+ * `connections` in ExplorerConfig), and a client-side `role === "admin"` would
2451
+ * be us guessing at an answer the server is willing to give.
2452
+ */
2453
+ const trashRetentionDays = ref<number | null>(null);
2454
+ /** True only when the server has confirmed this caller may purge. */
2455
+ const trashCanEmpty = ref(false);
2456
+ const trashEmptying = ref(false);
2457
+ const showTrashConfirm = ref(false);
2458
+
2459
+ async function probeTrashPolicy() {
2460
+ trashRetentionDays.value = null;
2461
+ trashCanEmpty.value = false;
2462
+ try {
2463
+ const res = await fetch(`${props.config.apiBase ?? ''}/api/admin/protection`, {
2464
+ headers: await buildAuthHeaders(),
2465
+ credentials: api.credentialsMode(),
2466
+ });
2467
+ /* ⚠ A 403 is the ANSWER "you are not an operator", not a failure: it is
2468
+ * the ordinary case for every end user, and it must not reach the error
2469
+ * emitter or the toast. */
2470
+ if (!res.ok) return;
2471
+ const body = (await res.json()) as { trash_retention_days?: unknown };
2472
+ const d = body?.trash_retention_days;
2473
+ if (typeof d === 'number' && d > 0) trashRetentionDays.value = d;
2474
+ trashCanEmpty.value = true;
2475
+ } catch {
2476
+ /* offline / CORS — same as "not allowed": say nothing we cannot verify */
2477
+ }
2478
+ }
2479
+
2480
+ /** What the banner promises. Named the number, or explicitly not. */
2481
+ const trashBannerText = computed(() =>
2482
+ trashRetentionDays.value !== null
2483
+ ? t('trash.retention', { days: trashRetentionDays.value })
2484
+ : t('trash.retention_unknown'),
2485
+ );
2486
+
2487
+ /** How much is about to go. The confirmation names both, because "empty the
2488
+ * trash?" with no quantity is a question nobody can answer. */
2489
+ const trashTotalBytes = computed(() =>
2490
+ files.value.reduce((sum, n) => {
2491
+ const v = typeof n.size === 'number' ? n.size : (n as Record<string, unknown>).file_size;
2492
+ return sum + (typeof v === 'number' ? v : 0);
2493
+ }, 0),
2494
+ );
2495
+ /** ⚠ A total of zero is reported as "we do not know", not as "0 B". A server
2496
+ * that does not send sizes would otherwise have us telling somebody that
2497
+ * deleting their files frees nothing, on the last screen before it happens.
2498
+ * A genuinely empty set never reaches here — the button is disabled. */
2499
+ const trashSizeKnown = computed(() => trashTotalBytes.value > 0);
2500
+ /** Which sentence the confirmation uses: with or without a size. Singular or
2501
+ * plural is `t()`'s business (composables/useLocale → countedKey). */
2502
+ const trashConfirmKey = computed(() =>
2503
+ trashSizeKnown.value ? 'trash.empty_confirm_body' : 'trash.empty_confirm_body_nosize',
2504
+ );
2505
+
2506
+ async function emptyTrash() {
2507
+ showTrashConfirm.value = false;
2508
+ if (!trashCanEmpty.value || trashEmptying.value) return;
2509
+ trashEmptying.value = true;
2510
+ try {
2511
+ const res = await fetch(`${props.config.apiBase ?? ''}/api/admin/trash/empty`, {
2512
+ method: 'POST',
2513
+ headers: await buildAuthHeaders(),
2514
+ credentials: api.credentialsMode(),
2515
+ });
2516
+ if (!res.ok) throw new Error(String(res.status));
2517
+ await loadTrash();
2518
+ flashToast(t('trash.emptied'));
2519
+ } catch (err) {
2520
+ const msg = err instanceof Error ? err.message : String(err);
2521
+ emit('error', { message: msg, context: { op: 'trash:empty' } });
2522
+ flashToast(msg);
2523
+ } finally {
2524
+ trashEmptying.value = false;
2525
+ }
2526
+ }
2527
+
1610
2528
  async function loadTrash() {
1611
2529
  loading.value = true;
1612
2530
  trashOrigin.value = adapter.value || '';
@@ -1625,6 +2543,12 @@ async function loadTrash() {
1625
2543
  extension: e.name.includes('.') ? e.name.split('.').pop() || '' : '',
1626
2544
  storage: e.storage_name || '',
1627
2545
  visibility: 'private',
2546
+ /* tablo:t1 — ⚠ BOTH. Every view reads `size` (the Size column, the
2547
+ info panel, the empty-trash confirmation); only the upload code
2548
+ reads `file_size`. Setting one of the two left every trashed row
2549
+ with a blank Size cell and made "this permanently deletes 2 items
2550
+ (0 B)" a false statement about two real files. */
2551
+ size: e.size,
1628
2552
  file_size: e.size,
1629
2553
  mime_type: e.mime || '',
1630
2554
  extra_metadata: { deleted_at: e.deleted_at, ttl_days: e.ttl_days ?? null },
@@ -1709,6 +2633,30 @@ function wireJoin(dir: string, name: string): string {
1709
2633
  return dir.endsWith('://') || dir.endsWith('/') ? dir + name : `${dir}/${name}`;
1710
2634
  }
1711
2635
 
2636
+ /**
2637
+ * Does anything in `targetWire` already carry the name of one of `sources`?
2638
+ * Asked BEFORE a move is queued, because the answer decides whether the move
2639
+ * can be undone.
2640
+ *
2641
+ * ⚠ The server never moves onto a taken name — it keeps both, and the moved
2642
+ * item lands as `name-copy` (ops.MoveDest). The undo moves `target/<name>`
2643
+ * back, so after a collision it would move the item that WAS ALREADY THERE.
2644
+ * A colliding move is therefore offered no undo, and says so.
2645
+ *
2646
+ * Case-insensitive, and "could not list" counts as a collision: both err on
2647
+ * the side of withholding an undo rather than offering one that moves the
2648
+ * wrong file.
2649
+ */
2650
+ async function movedNamesCollide(sources: string[], targetWire: string): Promise<boolean> {
2651
+ try {
2652
+ const res = await api.index(targetWire);
2653
+ const taken = new Set((res.files ?? []).map((f) => String(f.basename ?? '').toLowerCase()));
2654
+ return sources.some((s) => taken.has(wireBasename(s).toLowerCase()));
2655
+ } catch {
2656
+ return true;
2657
+ }
2658
+ }
2659
+
1712
2660
  // Register the inverse of a queued async move under its op id: once the op
1713
2661
  // settles OK, the toast offers "Geri Al" which queues the reverse move. The
1714
2662
  // inverse op deliberately gets NO undo entry of its own (no redo ping-pong).
@@ -1717,8 +2665,9 @@ function registerMoveUndo(
1717
2665
  sources: string[],
1718
2666
  targetWire: string,
1719
2667
  originWire: string | undefined,
2668
+ collides: boolean,
1720
2669
  ) {
1721
- if (!originWire || !targetWire) return;
2670
+ if (!originWire || !targetWire || collides) return;
1722
2671
  const movedPaths = sources.map((p) => wireJoin(targetWire, wireBasename(p)));
1723
2672
  if (movedPaths.length === 0) return;
1724
2673
  opUndo.set(opId, {
@@ -1806,8 +2755,19 @@ function writePersistedPath(path: string) {
1806
2755
  if ((window.location.hash || '') === target) return;
1807
2756
  // replaceState never fires `hashchange`, so onHashChange only ever sees
1808
2757
  // genuine external edits (paste, back/forward) — no self-echo to suppress.
2758
+ //
2759
+ // ⚠⚠ `history.state`, NEVER `null`. This mirrors the current folder into the
2760
+ // hash, and it runs inside a host that may be a router-driven SPA: the admin
2761
+ // app is vue-router, which keeps its own bookkeeping (scroll position, the
2762
+ // position counter, `back`/`forward` links) in `history.state`. Passing
2763
+ // `null` here erased it, vue-router warned
2764
+ // "history.state seems to have been manually replaced without preserving
2765
+ // the necessary values"
2766
+ // and the NEXT navigation — into Home, or out to the admin panel — rendered
2767
+ // a blank page. Measured 2026-09-13. Preserving the object costs nothing:
2768
+ // the explorer has no state of its own to put there, only a URL to change.
1809
2769
  history.replaceState(
1810
- null,
2770
+ history.state,
1811
2771
  '',
1812
2772
  target || window.location.pathname + window.location.search,
1813
2773
  );
@@ -1945,7 +2905,7 @@ useKeyboardShortcuts(rootEl, {
1945
2905
  onDelete: () => {
1946
2906
  /* ui-fix — the shortcut goes to the active pane too (consistent with the menu). */
1947
2907
  if (paneIsActive.value) {
1948
- const psel = splitPaneRef.value?.selectedNodes() ?? [];
2908
+ const psel = splitSelection.nodes.value;
1949
2909
  if (psel.length) {
1950
2910
  paneCtxTargets.value = psel;
1951
2911
  mutationInPane.value = true;
@@ -1958,7 +2918,7 @@ useKeyboardShortcuts(rootEl, {
1958
2918
  },
1959
2919
  onRename: () => {
1960
2920
  if (paneIsActive.value) {
1961
- const psel = splitPaneRef.value?.selectedNodes() ?? [];
2921
+ const psel = splitSelection.nodes.value;
1962
2922
  if (psel.length === 1) {
1963
2923
  renameTarget.value = psel[0];
1964
2924
  mutationInPane.value = true;
@@ -1970,9 +2930,16 @@ useKeyboardShortcuts(rootEl, {
1970
2930
  showRename.value = true;
1971
2931
  }
1972
2932
  },
1973
- onSelectAll: () => (paneIsActive.value ? splitPaneRef.value?.selectAll() : selection.selectAll()) /* wiring:d1 pane-route */,
2933
+ onSelectAll: () => (paneIsActive.value ? splitSelection.selectAll() : selection.selectAll()) /* wiring:d1 pane-route */,
1974
2934
  onOpen: () => {
1975
- if (paneIsActive.value) return splitPaneRef.value?.openSelected(); /* wiring:d1 pane-route */
2935
+ /* wiring:d1 pane-route — one `openNode`, whichever pane asked. A folder
2936
+ opens IN the pane that had the keyboard; a file opens in the preview,
2937
+ which is the window's, so there is nothing to route. */
2938
+ if (paneIsActive.value) {
2939
+ const pn = splitSelection.nodes.value[0];
2940
+ if (pn) onPaneOpen('split', pn);
2941
+ return;
2942
+ }
1976
2943
  const n = selection.nodes.value[0];
1977
2944
  if (n) openNode(n);
1978
2945
  },
@@ -1980,7 +2947,6 @@ useKeyboardShortcuts(rootEl, {
1980
2947
  showNewFolder.value = false;
1981
2948
  showRename.value = false;
1982
2949
  showDelete.value = false;
1983
- showShare.value = false;
1984
2950
  showPreview.value = false;
1985
2951
  ctxRef.value?.hide();
1986
2952
  dismissToast();
@@ -2029,9 +2995,51 @@ useKeyboardShortcuts(rootEl, {
2029
2995
  onTabNext: () => nextTab(),
2030
2996
  onTabPrev: () => prevTab(),
2031
2997
  /* /wiring:d1 */
2998
+ /* tus:t1 — the menu verbs, routed through the SAME dispatcher the right-click
2999
+ * menu and the toolbar use (dispatchItemAction), so a key and a click cannot
3000
+ * drift apart. The ones that act on a selection do nothing without one, which
3001
+ * is what the menu does too. */
3002
+ onNewFolder: () => {
3003
+ showNewFolder.value = true;
3004
+ },
3005
+ onUpload: () => triggerUpload(),
3006
+ onRefresh: () => void load(),
3007
+ onDownload: () => void dispatchItemAction('download', activeTargets()),
3008
+ onPreview: () => void dispatchItemAction('preview', activeTargets()),
3009
+ onShare: () => void dispatchItemAction('access', activeTargets()),
3010
+ onTags: () => void dispatchItemAction('tags', activeTargets()),
3011
+ onConvert: () => void dispatchItemAction('convert', activeTargets()),
3012
+ onOpenTab: () => void dispatchItemAction('open-tab', activeTargets()),
3013
+ onCopyPath: () => {
3014
+ const n = activeTargets()[0];
3015
+ if (n) void onCopyPath(n.path);
3016
+ },
3017
+ onCopyId: () => void dispatchItemAction('copy-id', activeTargets()),
3018
+ onRestore: () => void dispatchItemAction('restore', activeTargets()),
3019
+ /* /tus:t1 */
2032
3020
  hasSelection: () => !selection.isEmpty.value,
2033
3021
  });
2034
3022
 
3023
+ /**
3024
+ * tus:t1 / pane:p1 — THE rows a verb acts on, whoever asked for it.
3025
+ *
3026
+ * The active pane's selection when the split pane has focus, otherwise the
3027
+ * main listing's. It began as the keyboard's rule (hence tus:t1) and it is now
3028
+ * everybody's: the keyboard shortcuts, the selection bar's count and mode, the
3029
+ * action list the bar renders and the handler it dispatches through all read
3030
+ * this one function.
3031
+ *
3032
+ * ⚠ Measured 2026-09-13, before that was true: the toolbar read
3033
+ * `selection.nodes` directly, so ticking rows in the RIGHT pane raised no bar
3034
+ * at all while the LEFT pane's bar went on describing a selection nobody was
3035
+ * touching. Two answers to "what is selected" is how that happens; there is
3036
+ * one now.
3037
+ */
3038
+ function activeTargets(): FileNode[] {
3039
+ if (paneIsActive.value) return splitSelection.nodes.value;
3040
+ return selection.nodes.value;
3041
+ }
3042
+
2035
3043
  // --------------------------------------------------------------------
2036
3044
  // Actions
2037
3045
  // --------------------------------------------------------------------
@@ -2138,8 +3146,12 @@ async function restoreSelection(targets?: FileNode[]) {
2138
3146
  const ids = nodes
2139
3147
  .map((n) => (n as { id?: number }).id)
2140
3148
  .filter((x): x is number => typeof x === 'number');
2141
- const { restored } = await api.restoreIds(ids);
2142
- flashToast(t('toast.restored', { n: restored }));
3149
+ const { restored, taken } = await api.restoreIds(ids);
3150
+ flashToast(
3151
+ taken.length
3152
+ ? t('toast.restore_taken', { n: taken.length, name: taken[0] })
3153
+ : t('toast.restored', { n: restored }),
3154
+ );
2143
3155
  selection.clear();
2144
3156
  await loadTrash();
2145
3157
  return;
@@ -2228,15 +3240,18 @@ const breadcrumbCtxPath = ref<string>('');
2228
3240
  const paneCtxTargets = ref<FileNode[]>([]);
2229
3241
  const breadcrumbCtxLabel = ref<string>('');
2230
3242
 
3243
+ /* pane:p1 — `activeTargets()`, not `selection`: the bar describes the pane the
3244
+ * keyboard is in. See the function's own note for what reading `selection`
3245
+ * directly here used to cost. */
2231
3246
  const selectionMode = computed<SelectionMode>(() => {
2232
- const sel = selection.nodes.value;
3247
+ const sel = activeTargets();
2233
3248
  if (sel.length === 0) return 'none';
2234
3249
  if (sel.length === 1) return sel[0].type === 'dir' ? 'single-dir' : 'single-file';
2235
3250
  return 'multi';
2236
3251
  });
2237
3252
 
2238
3253
  async function onToolbarAction(key: string) {
2239
- const sel = selection.nodes.value;
3254
+ const sel = activeTargets();
2240
3255
  // The toolbar's "Aç" opens the in-page preview/editor modal (quick peek);
2241
3256
  // everything else shares dispatchItemAction with the context menu so the two
2242
3257
  // identical menus also behave identically.
@@ -2398,13 +3413,25 @@ function keepActionsFor(sel: FileNode[]): ContextAction[] {
2398
3413
  const st = keepStateOf(keepRemoteOf(sel[0]!));
2399
3414
  return [
2400
3415
  { divider: true, key: 'sep-keep', label: '' },
2401
- { key: 'keep-local', label: t('ctx.keep_local'), icon: '📌', hidden: st === 'kept' || st === 'inherited' },
2402
- { key: 'keep-online', label: t('ctx.keep_online'), icon: '☁', hidden: st !== 'kept' },
2403
- { key: 'keep-inherited', label: t('ctx.keep_inherited'), icon: '📌', disabled: true, hidden: st !== 'inherited' },
2404
- { key: 'keep-reveal', label: t('ctx.keep_reveal'), icon: '📂', hidden: st !== 'kept' && st !== 'inherited' },
3416
+ { key: 'keep-local', label: t('ctx.keep_local'), hidden: st === 'kept' || st === 'inherited' },
3417
+ { key: 'keep-online', label: t('ctx.keep_online'), hidden: st !== 'kept' },
3418
+ { key: 'keep-inherited', label: t('ctx.keep_inherited'), disabled: true, hidden: st !== 'inherited' },
3419
+ { key: 'keep-reveal', label: t('ctx.keep_reveal'), hidden: st !== 'kept' && st !== 'inherited' },
2405
3420
  ];
2406
3421
  }
2407
3422
 
3423
+ /**
3424
+ * The context target when it is NOT part of this pane's listing.
3425
+ *
3426
+ * Home's Recent and Starred cards are the only rows in the product that a
3427
+ * person can right-click without them being in `files` — they come from their
3428
+ * own endpoints. `selection.nodes` can never resolve them (it filters the
3429
+ * listing by selected path), so without this the menu is built from an empty
3430
+ * selection and renders the blank-canvas menu. Empty in every other case, so
3431
+ * the ordinary selection path is unaffected.
3432
+ */
3433
+ const ctxUnlistedTargets = ref<FileNode[]>([]);
3434
+
2408
3435
  async function onContextTarget(node: FileNode, ev: MouseEvent) {
2409
3436
  ctxMode.value = 'selection';
2410
3437
  void refreshKept(); // menu labels react if the kept set changed since last look
@@ -2412,13 +3439,40 @@ async function onContextTarget(node: FileNode, ev: MouseEvent) {
2412
3439
  selection.click(node.path);
2413
3440
  await nextTick();
2414
3441
  }
2415
- ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, selection.nodes.value);
3442
+ // ⚠⚠ Fall back to the node that was actually clicked.
3443
+ //
3444
+ // `selection.nodes` is the CURRENT LISTING filtered by the selected paths
3445
+ // (`useSelection(() => displayOrder ?? files)`), so it can only ever resolve
3446
+ // a row that is in this pane's listing. Home is not a listing: its Recent
3447
+ // and Starred cards come from their own endpoints and are absent from
3448
+ // `files`, so selecting one left `selection.nodes` EMPTY and the menu opened
3449
+ // with zero targets — which is the signature of a right-click on blank
3450
+ // canvas. Measured 2026-09-13: the ⋮ and the right-click on every Home card
3451
+ // opened a one-line "Show hidden files" menu instead of the fifteen-line
3452
+ // file menu, in both Recent and Starred. HomeView's own header says these
3453
+ // cards carry "the same right-click menu as the listing", so this is the
3454
+ // contract being restored, not a new behaviour.
3455
+ //
3456
+ // In a real listing `selection.nodes` is non-empty by the line above, so
3457
+ // multi-selection is untouched — this only rescues the case where the path
3458
+ // cannot be resolved against the current pane.
3459
+ //
3460
+ // ⚠ BOTH halves are needed. `show()` decides what the chosen action RUNS on;
3461
+ // `contextActions` decides what the menu LISTS, and it reads
3462
+ // `selection.nodes` on its own. Setting only the first left the actions
3463
+ // correct and the menu still empty, which looks identical to the bug.
3464
+ ctxUnlistedTargets.value = selection.nodes.value.length ? [] : [node];
3465
+ const targets = selection.nodes.value.length ? selection.nodes.value : [node];
3466
+ ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, targets);
2416
3467
  }
2417
3468
 
2418
3469
  function onContextCanvas(ev: MouseEvent) {
2419
3470
  ev.preventDefault();
2420
3471
  ctxMode.value = 'selection';
2421
3472
  selection.clear();
3473
+ // Blank canvas has no target — drop any node left over from a card menu, or
3474
+ // the next right-click on empty space would offer that file's actions.
3475
+ ctxUnlistedTargets.value = [];
2422
3476
  ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, []);
2423
3477
  }
2424
3478
 
@@ -2429,27 +3483,45 @@ function onCrumbContext(payload: { x: number; y: number; adapterPath: string; la
2429
3483
  ctxRef.value?.show({ clientX: payload.x, clientY: payload.y }, []);
2430
3484
  }
2431
3485
 
2432
- /* ui-fix — right-click in the side (secondary) pane: activate the pane, then
2433
- * open the menu. The menu is EXACTLY the main pane's (selectionActionList is
2434
- * the single source); actions go to dispatchItemAction and are pane-routed
2435
- * while ctxMode==='pane'. */
2436
- function onPaneContext(node: FileNode | null, ev: MouseEvent) {
2437
- activePane.value = 'split';
2438
- void refreshKept();
2439
- const sel = splitPaneRef.value?.selectedNodes() ?? [];
2440
- // node=null (right-click on empty space): the selection-less menu
2441
- // ("Yeni Klasör" + "Yapıştır"). Otherwise the pane selection is the target
2442
- // (falling back to the clicked node).
2443
- paneCtxTargets.value = node ? (sel.length > 0 ? sel : [node]) : [];
2444
- ctxMode.value = 'pane';
2445
- ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, paneCtxTargets.value);
3486
+ /**
3487
+ * pane:p1 right-click in EITHER pane, through one door.
3488
+ *
3489
+ * The menu itself was already single-source (`selectionActionList`); this is
3490
+ * the other half the two panes no longer reach it through two different
3491
+ * handlers, so a change to how a right-click picks its targets cannot land in
3492
+ * one pane and miss the other. `node === null` is a right-click on empty
3493
+ * space: the selection-less menu ("New folder" + "Paste").
3494
+ */
3495
+ async function onPaneMenu(pane: 'main' | 'split', node: FileNode | null, ev: MouseEvent) {
3496
+ void refreshKept(); // menu labels react if the kept set changed since last look
3497
+ if (pane === 'split') {
3498
+ activePane.value = 'split';
3499
+ const sel = splitSelection.nodes.value;
3500
+ if (node && !splitSelection.has(node.path)) {
3501
+ splitSelection.click(node.path);
3502
+ await nextTick();
3503
+ }
3504
+ const after = splitSelection.nodes.value;
3505
+ paneCtxTargets.value = node ? (after.length > 0 ? after : (sel.length > 0 ? sel : [node])) : [];
3506
+ ctxMode.value = 'pane';
3507
+ ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, paneCtxTargets.value);
3508
+ return;
3509
+ }
3510
+ activePane.value = 'main';
3511
+ ctxMode.value = 'selection';
3512
+ if (!node) {
3513
+ selection.clear();
3514
+ ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, []);
3515
+ return;
3516
+ }
3517
+ await onContextTarget(node, ev);
2446
3518
  }
2447
3519
 
2448
3520
  const contextActions = computed<ContextAction[]>(() => {
2449
3521
  if (ctxMode.value === 'breadcrumb') {
2450
3522
  return [
2451
- { key: 'open', label: t('ctx.open'), icon: '↗' },
2452
- { key: 'copy-path', label: t('breadcrumb.copy_path'), icon: '📋' },
3523
+ { key: 'open', label: t('ctx.open') },
3524
+ { key: 'copy-path', label: t('breadcrumb.copy_path') },
2453
3525
  ];
2454
3526
  }
2455
3527
  if (ctxMode.value === 'pane' /* ui-fix — side-pane menu is EXACTLY the main pane's */) {
@@ -2457,23 +3529,25 @@ const contextActions = computed<ContextAction[]>(() => {
2457
3529
  if (psel.length === 0) {
2458
3530
  // Right-click on empty space: same as the main pane's canvas menu.
2459
3531
  return [
2460
- { key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' },
2461
- { key: 'paste', label: t('ctx.paste'), icon: '📋', disabled: !clipboard.value.mode },
3532
+ { key: 'new-folder', label: t('toolbar.new_folder') },
3533
+ { key: 'paste', label: t('ctx.paste'), disabled: !clipboard.value.mode },
2462
3534
  ];
2463
3535
  }
2464
3536
  return selectionActionList(psel);
2465
3537
  }
2466
3538
 
2467
- const sel = selection.nodes.value;
3539
+ // `ctxUnlistedTargets` is non-empty only for a row that exists outside this
3540
+ // pane's listing (Home's Recent / Starred cards) — see `onContextTarget`.
3541
+ const sel = selection.nodes.value.length ? selection.nodes.value : ctxUnlistedTargets.value;
2468
3542
  const any = sel.length > 0;
2469
3543
  const single = sel.length === 1;
2470
3544
 
2471
3545
  if (trashActive.value) {
2472
3546
  if (!any) return [];
2473
3547
  return [
2474
- { key: 'restore', label: t('ctx.restore'), icon: '↩' },
3548
+ { key: 'restore', label: t('ctx.restore') },
2475
3549
  { divider: true, key: 'sep1', label: '' },
2476
- { key: 'delete', label: t('ctx.delete_perm'), icon: '🗑', danger: true },
3550
+ { key: 'delete', label: t('ctx.delete_perm'), danger: true },
2477
3551
  ];
2478
3552
  }
2479
3553
 
@@ -2495,8 +3569,8 @@ const contextActions = computed<ContextAction[]>(() => {
2495
3569
  if (!any) return [];
2496
3570
  if (!single) return [];
2497
3571
  return [
2498
- { key: 'open', label: t('ctx.open'), icon: '↗' },
2499
- { key: 'open-tab', label: t('ctx.open_new_tab'), icon: '⧉' } /* wiring:d1 */,
3572
+ { key: 'open', label: t('ctx.open') },
3573
+ { key: 'open-tab', label: t('ctx.open_new_tab') } /* wiring:d1 */,
2500
3574
  // A whole storage can be kept too — that IS the "sync everything"
2501
3575
  // shape, and it is one pair, not one per subfolder.
2502
3576
  ...keepActionsFor(sel),
@@ -2512,13 +3586,12 @@ const contextActions = computed<ContextAction[]>(() => {
2512
3586
  {
2513
3587
  key: 'toggle-hidden',
2514
3588
  label: showHiddenFiles.value ? t('ctx.hide_hidden') : t('ctx.show_hidden'),
2515
- icon: showHiddenFiles.value ? '🙈' : '👁',
2516
3589
  },
2517
3590
  ];
2518
3591
  if (!permCanEdit(dirPerm.value)) return view;
2519
3592
  return [
2520
- { key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' },
2521
- { key: 'paste', label: t('ctx.paste'), icon: '📋', disabled: !clipboard.value.mode },
3593
+ { key: 'new-folder', label: t('toolbar.new_folder') },
3594
+ { key: 'paste', label: t('ctx.paste'), disabled: !clipboard.value.mode },
2522
3595
  ...view,
2523
3596
  ];
2524
3597
  }
@@ -2535,13 +3608,28 @@ const contextActions = computed<ContextAction[]>(() => {
2535
3608
  function selectionActionList(sel: FileNode[]): ContextAction[] {
2536
3609
  const any = sel.length > 0;
2537
3610
  const single = sel.length === 1;
3611
+ /* pane:p1 — a STORAGE row is a mount point, not a file: rename, delete, cut,
3612
+ * copy and share all 4xx on it. The main listing answers this before it ever
3613
+ * gets here (the `inStorageRoot` branch in `contextActions`), but the split
3614
+ * pane lists the same virtual rows through no such branch — so its
3615
+ * right-click menu has been offering "Sil" on a whole storage, and with the
3616
+ * selection bar now reaching both panes it would be one click. Measured
3617
+ * 2026-09-13: download/share/cut/copy/delete, all of them, on `depoB`.
3618
+ * Answered HERE because this is the one list both surfaces render. */
3619
+ if (sel.some(isStorageRow)) {
3620
+ if (!single) return [];
3621
+ return [
3622
+ { key: 'open', label: t('ctx.open') },
3623
+ { key: 'open-tab', label: t('ctx.open_new_tab') },
3624
+ ...keepActionsFor(sel),
3625
+ ];
3626
+ }
2538
3627
  const isFile = single && sel[0]?.type === 'file';
2539
3628
  const tagsLabel = locale.value === 'en' ? 'Tags…' : 'Etiketler…';
2540
3629
  const singleHasId = single && typeof sel[0]?.id === 'number';
2541
3630
  /* yildiz:s1 */
2542
3631
  const canStar = starableNodes(sel).length > 0;
2543
3632
  const allStarred = selectionAllStarred(sel);
2544
- const copyIdLabel = locale.value === 'en' ? 'Copy node id' : "Node id'yi kopyala";
2545
3633
  // RBAC: gate mutating actions when the caller lacks edit on the target. The
2546
3634
  // "İzinler" (permissions) action shows only for owners on RBAC-on storages.
2547
3635
  const p = selPerm(sel);
@@ -2553,19 +3641,52 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
2553
3641
  // picks the action from inside the modal, so there's no separate button.
2554
3642
  const accessLabel = locale.value === 'en' ? 'Share / Permissions' : 'Paylaş / İzinler';
2555
3643
  return [
2556
- { key: 'open', label: t('ctx.open'), icon: '↗', hidden: !single },
2557
- { key: 'open-tab', label: t('ctx.open_new_tab'), icon: '⧉', hidden: !single || sel[0]?.type !== 'dir' } /* wiring:d1 — open the folder in a new tab */,
2558
- { key: 'preview', label: t('ctx.preview'), icon: '👁', hidden: !single, disabled: !isFile },
2559
- { key: 'download', label: t('ctx.download'), icon: '⬇', hidden: !single, disabled: !isFile },
2560
- { key: 'convert', label: t('ctx.convert'), icon: '🔄', hidden: !single || !effectiveConvertUrl.value || !w || e2eActive.value /* wiring:e2 — convert is meaningless on ciphertext */, disabled: !isFile },
2561
- { key: 'access', label: accessLabel, icon: '🔗', hidden: !single || !w || e2eActive.value /* wiring:e2 sharing is off in the MVP (the link would serve ciphertext) */ },
2562
- { key: 'details', label: t('ctx.details'), icon: 'ℹ', hidden: !any } /* koru:k1 */,
2563
- { key: 'copy-id', label: copyIdLabel, icon: '🆔', hidden: !singleHasId, disabled: !singleHasId },
3644
+ { key: 'open', label: t('ctx.open'), hidden: !single },
3645
+ { key: 'open-tab', label: t('ctx.open_new_tab'), hidden: !single || sel[0]?.type !== 'dir' } /* wiring:d1 — open the folder in a new tab */,
3646
+ { key: 'preview', label: t('ctx.preview'), hidden: !single, disabled: !isFile },
3647
+ /* tasi:m1 Download works on ANY selection now. It used to disappear the
3648
+ moment a second row was ticked, because the only implementation was one
3649
+ `window.open` per node and the browser blocks the second popup; there is
3650
+ one streaming archive behind it now (lib/downloadSelection), and the
3651
+ server expands a selected folder itself, so a lone folder is a zip too.
3652
+ ⚠ Still single-only inside an encrypted folder: those bytes are
3653
+ decrypted IN THE BROWSER, one file at a time, and the server has no
3654
+ plaintext to zip. */
3655
+ { key: 'download', label: t('ctx.download'), hidden: !any || (e2eActive.value && !single), disabled: !any },
3656
+ { key: 'convert', label: t('ctx.convert'), hidden: !single || !effectiveConvertUrl.value || !w || e2eActive.value /* wiring:e2 — convert is meaningless on ciphertext */, disabled: !isFile },
3657
+ /* tasi:m1 — VISIBLE and grey above a multi-selection, not gone. Sharing
3658
+ really is one item at a time (a share link addresses one node), and the
3659
+ row now says so in its tooltip; vanishing taught the reader that filex
3660
+ cannot share the thing they are looking at. */
3661
+ {
3662
+ key: 'access',
3663
+ label: accessLabel,
3664
+ hidden: !any || !w || e2eActive.value /* wiring:e2 — sharing is off in the MVP (the link would serve ciphertext) */,
3665
+ disabled: !single,
3666
+ title: single ? undefined : t('ctx.access.one_only'),
3667
+ },
3668
+ { key: 'details', label: t('ctx.details'), hidden: !any } /* koru:k1 */,
3669
+ /* ⚠ "Copy node id" is NOT here any more (owner's call, 2026-09-13): it is a
3670
+ developer's handle on a support ticket, not an everyday verb, and this
3671
+ list is rendered by BOTH the right-click menu and the selection bar — so
3672
+ one row put it in front of everyone, twice. It lives in the details
3673
+ panel now, beside Path and ETag, which is where the other technical
3674
+ facts about a file already are. The `copy-id` case below stays: the
3675
+ panel dispatches it. */
2564
3676
  { divider: true, key: 'sep1', label: '', hidden: !w },
2565
- { key: 'rename', label: t('ctx.rename'), icon: '✎', hidden: !single || !w, disabled: !single },
2566
- { key: 'cut', label: t('ctx.cut'), icon: '✂', hidden: !any || !w, disabled: !any },
2567
- { key: 'copy', label: t('ctx.copy'), icon: '❐', hidden: !any, disabled: !any },
2568
- { key: 'paste', label: t('ctx.paste'), icon: '📋', hidden: !w, disabled: !clipboard.value.mode },
3677
+ { key: 'rename', label: t('ctx.rename'), hidden: !single || !w, disabled: !single },
3678
+ { key: 'cut', label: t('ctx.cut'), hidden: !any || !w, disabled: !any },
3679
+ { key: 'copy', label: t('ctx.copy'), hidden: !any, disabled: !any },
3680
+ /* tasi:m1 "somewhere else", without the clipboard. Cut+paste has always
3681
+ been able to do this, but only by navigating away from the rows you had
3682
+ just picked; these two ask WHERE in a dialog and leave the listing where
3683
+ it is.
3684
+ ⚠ `icon:` is not decoration here — `actionIconSvg` answers '' for a key
3685
+ it does not know, and a bar button with no glyph is an empty 28px box.
3686
+ They borrow the clipboard verbs' marks, which is what they are. */
3687
+ { key: 'move-to', label: t('ctx.move_to'), icon: 'cut', hidden: !any || !w, disabled: !any },
3688
+ { key: 'copy-to', label: t('ctx.copy_to'), icon: 'copy', hidden: !any, disabled: !any },
3689
+ { key: 'paste', label: t('ctx.paste'), hidden: !w, disabled: !clipboard.value.mode },
2569
3690
  { divider: true, key: 'sep-meta', label: '', hidden: !singleHasId && !canStar },
2570
3691
  /* yildiz:s1 — "star must be an action, like a tag" (owner, v0.30.0).
2571
3692
  Beside Tags on purpose: they are the same kind of verb, and this is the
@@ -2574,13 +3695,13 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
2574
3695
  {
2575
3696
  key: 'star',
2576
3697
  label: allStarred ? t('ctx.unstar') : t('ctx.star'),
2577
- icon: allStarred ? '' : '',
3698
+ icon: allStarred ? 'unstar' : 'star' /* gorunum:v1-icons — names the glyph, not an emoji */,
2578
3699
  hidden: !canStar,
2579
3700
  },
2580
- { key: 'tags', label: tagsLabel, icon: '🏷', hidden: !singleHasId, disabled: !singleHasId },
3701
+ { key: 'tags', label: tagsLabel, hidden: !singleHasId, disabled: !singleHasId },
2581
3702
  ...keepActionsFor(sel),
2582
3703
  { divider: true, key: 'sep2', label: '', hidden: !w },
2583
- { key: 'delete', label: t('ctx.delete'), icon: '🗑', danger: true, hidden: !any || !w, disabled: !any },
3704
+ { key: 'delete', label: t('ctx.delete'), danger: true, hidden: !any || !w, disabled: !any },
2584
3705
  ];
2585
3706
  }
2586
3707
 
@@ -2588,17 +3709,22 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
2588
3709
  // two stay identical for a selection; the empty/trash/virtual-root cases match
2589
3710
  // the context menu's special branches.
2590
3711
  const toolbarActions = computed<ContextAction[]>(() => {
2591
- const sel = selection.nodes.value;
3712
+ const sel = activeTargets();
3713
+ /* pane:p1 — the split pane's bar offers EXACTLY what its own right-click
3714
+ * menu offers (`contextActions`, ctxMode 'pane'). The three special branches
3715
+ * below describe the MAIN listing's state — the trash, the multi-storage
3716
+ * root — and the split pane reaches neither through this computed. */
3717
+ if (paneIsActive.value) return sel.length ? selectionActionList(sel) : [];
2592
3718
  if (trashActive.value) {
2593
3719
  if (sel.length === 0) return [];
2594
3720
  return [
2595
- { key: 'restore', label: t('ctx.restore'), icon: '↩' },
2596
- { key: 'delete', label: t('ctx.delete_perm'), icon: '🗑', danger: true },
3721
+ { key: 'restore', label: t('ctx.restore') },
3722
+ { key: 'delete', label: t('ctx.delete_perm'), danger: true },
2597
3723
  ];
2598
3724
  }
2599
3725
  const trimmedPath = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
2600
3726
  if (multiStorageRoot.value && trimmedPath === '') {
2601
- return sel.length === 1 ? [{ key: 'open', label: t('ctx.open'), icon: '↗' }] : [];
3727
+ return sel.length === 1 ? [{ key: 'open', label: t('ctx.open') }] : [];
2602
3728
  }
2603
3729
  if (sel.length === 0) return [];
2604
3730
  return selectionActionList(sel);
@@ -2624,8 +3750,34 @@ async function onContextAction(action: ContextAction, targets: FileNode[]) {
2624
3750
  // the right-click menu (onContextAction) and the toolbar (onToolbarAction)
2625
3751
  // route here, so the two menus that now render the SAME list also behave the
2626
3752
  // same. (Toolbar "Aç" is the one deliberate exception — see onToolbarAction.)
3753
+ /**
3754
+ * pane:p1 — is this verb mutating the SPLIT pane?
3755
+ *
3756
+ * Two signals, and they agree in every case a menu produced: the pane menu
3757
+ * names itself (`ctxMode === 'pane'`), and every other door — the selection
3758
+ * bar, the keyboard — means "the pane that has the keyboard".
3759
+ *
3760
+ * ⚠ Reading only `ctxMode` was safe only while the toolbar acted on
3761
+ * `selection` no matter which pane had focus. It does not any more (see
3762
+ * `activeTargets`), so a Cut fired from the bar over the right-hand pane would
3763
+ * have taken the right pane's ROWS with the left pane's FOLDER as their
3764
+ * origin, and the paste would then have moved them out of a directory they
3765
+ * were never in.
3766
+ */
3767
+ function actingInPane(): boolean {
3768
+ return ctxMode.value === 'pane' || paneIsActive.value;
3769
+ }
3770
+
2627
3771
  async function dispatchItemAction(key: string, targets: FileNode[]) {
2628
3772
  switch (key) {
3773
+ /* gorunum:v1 — the selection bar's × . It used to be delivered by
3774
+ * synthesising a click on the listing's background, because nothing here
3775
+ * answered the key; a gesture that works by imitating another gesture
3776
+ * breaks the first time that other one changes. */
3777
+ case 'clear-selection':
3778
+ if (paneIsActive.value) splitSelection.clear();
3779
+ else selection.clear();
3780
+ return;
2629
3781
  case 'open':
2630
3782
  // Context-menu "Aç" launches the standalone fullscreen route
2631
3783
  // in a new tab. Double-click (openNode) opens the in-page
@@ -2637,7 +3789,16 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
2637
3789
  if (targets[0]) previewNode(targets[0]);
2638
3790
  break;
2639
3791
  case 'download':
2640
- if (targets[0]) downloadFile(targets[0]);
3792
+ await downloadSelection(targets);
3793
+ break;
3794
+ /* tasi:m1 — the same dialog, twice; only `mode` differs. */
3795
+ case 'move-to':
3796
+ case 'copy-to':
3797
+ if (targets.length === 0) break;
3798
+ destPickerMode.value = key === 'move-to' ? 'move' : 'copy';
3799
+ destPickerTargets.value = targets;
3800
+ destPickerBusy.value = false;
3801
+ showDestPicker.value = true;
2641
3802
  break;
2642
3803
  case 'keep-local': {
2643
3804
  const ds = desktopSync.value;
@@ -2674,9 +3835,6 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
2674
3835
  case 'convert':
2675
3836
  if (targets[0]) openConvert(targets[0]);
2676
3837
  break;
2677
- case 'share':
2678
- if (targets[0]) openShare(targets[0]);
2679
- break;
2680
3838
  case 'access':
2681
3839
  if (targets[0]) {
2682
3840
  permTarget.value = targets[0];
@@ -2704,20 +3862,20 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
2704
3862
  case 'rename':
2705
3863
  if (targets[0]) {
2706
3864
  renameTarget.value = targets[0];
2707
- mutationInPane.value = ctxMode.value === 'pane'; /* ui-fix */
3865
+ mutationInPane.value = actingInPane(); /* ui-fix */
2708
3866
  showRename.value = true;
2709
3867
  }
2710
3868
  break;
2711
3869
  case 'cut':
2712
3870
  /* ui-fix — in a pane context the clipboard source must be the pane's dir. */
2713
- if (ctxMode.value === 'pane') paneCut();
3871
+ if (actingInPane()) paneCut();
2714
3872
  else {
2715
3873
  clipboard.value = { mode: 'cut', items: targets, sourcePath: currentPath.value };
2716
3874
  flashToast(t('toast.cut_ready'));
2717
3875
  }
2718
3876
  break;
2719
3877
  case 'copy':
2720
- if (ctxMode.value === 'pane') paneCopy();
3878
+ if (actingInPane()) paneCopy();
2721
3879
  else {
2722
3880
  clipboard.value = { mode: 'copy', items: targets, sourcePath: currentPath.value };
2723
3881
  flashToast(t('toast.copy_ready'));
@@ -2726,13 +3884,20 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
2726
3884
  case 'paste':
2727
3885
  /* ui-fix — pasting from the right-click menu goes to the active pane too
2728
3886
  * (the keyboard shortcut was already pane-routed; the menu was not). */
2729
- if (ctxMode.value === 'pane' || paneIsActive.value) await panePaste();
3887
+ if (actingInPane()) await panePaste();
2730
3888
  else await paste();
2731
3889
  break;
2732
- case 'delete':
2733
- mutationInPane.value = ctxMode.value === 'pane'; /* ui-fix */
3890
+ case 'delete': {
3891
+ /* The confirm dialog reads `paneCtxTargets` in pane mode, and only the
3892
+ * pane MENU used to fill it. Coming from the selection bar there is no
3893
+ * menu, so the rows are handed over here or the dialog would delete
3894
+ * whatever the last right-click left behind. */
3895
+ const inPane = actingInPane(); /* ui-fix */
3896
+ if (inPane) paneCtxTargets.value = targets;
3897
+ mutationInPane.value = inPane;
2734
3898
  showDelete.value = true;
2735
3899
  break;
3900
+ }
2736
3901
  case 'restore':
2737
3902
  if (targets.length > 0) await restoreSelection(targets);
2738
3903
  break;
@@ -2740,7 +3905,7 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
2740
3905
  toggleHiddenFiles();
2741
3906
  break;
2742
3907
  case 'new-folder':
2743
- mutationInPane.value = ctxMode.value === 'pane'; /* ui-fix */
3908
+ mutationInPane.value = actingInPane(); /* ui-fix */
2744
3909
  showNewFolder.value = true;
2745
3910
  break;
2746
3911
  case 'duplicate':
@@ -2786,10 +3951,17 @@ async function paste() {
2786
3951
  const plan = resolveTransfer(items, targetWire, cb.mode === 'cut' ? 'move' : 'copy');
2787
3952
  if (cb.mode === 'cut') {
2788
3953
  const originWire = qualify(sourceDir) || undefined;
3954
+ const collides = await movedNamesCollide(items, targetWire);
2789
3955
  const { op } = await api.moveAsync(items, targetWire, originWire);
2790
- registerMoveUndo(op.id, items, targetWire, originWire);
3956
+ registerMoveUndo(op.id, items, targetWire, originWire, collides);
2791
3957
  pendingOps.register(op);
2792
- flashToast(plan.cross ? t('split.cross_move') : t('split.move_queued'));
3958
+ flashToast(
3959
+ collides
3960
+ ? t('toast.move_kept_both')
3961
+ : plan.cross
3962
+ ? t('split.cross_move')
3963
+ : t('split.move_queued'),
3964
+ );
2793
3965
  } else {
2794
3966
  const { op } = await api.copy(items, targetWire);
2795
3967
  pendingOps.register(op);
@@ -2826,8 +3998,105 @@ function downloadFile(n: FileNode) {
2826
3998
  window.open(url, '_blank');
2827
3999
  }
2828
4000
 
4001
+ /**
4002
+ * tasi:m1 — download WHATEVER is selected, as one thing.
4003
+ *
4004
+ * One file is still one navigation to its own body: it keeps the range
4005
+ * requests, the browser's own resume, and the encrypted-folder path that has
4006
+ * to decrypt in this tab. Anything else — several files, or a folder, whose
4007
+ * members the server expands itself — is one streaming archive behind a
4008
+ * single-use ticket (lib/downloadSelection explains why a POST then a
4009
+ * navigation, and why an iframe rather than `window.open`).
4010
+ *
4011
+ * ⚠ The old behaviour was not "download each one": the row HID itself above a
4012
+ * single selection, because the only implementation was one `window.open` per
4013
+ * node and the browser blocks the second as a popup.
4014
+ */
4015
+ async function downloadSelection(targets: FileNode[]): Promise<void> {
4016
+ if (targets.length === 0) return;
4017
+ if (targets.length === 1 && targets[0].type === 'file') {
4018
+ downloadFile(targets[0]);
4019
+ return;
4020
+ }
4021
+ /* ⚠ Not a courtesy: minting asks the server to resolve and authorize every
4022
+ * member, which on a deep folder is not instant, and nothing else on screen
4023
+ * moves until the browser is handed the response. */
4024
+ flashToast(t('toast.archive.preparing'));
4025
+ try {
4026
+ const ticket = await downloadArchive(api, targets.map((n) => n.path));
4027
+ flashToast(t('toast.archive.started', { name: ticket.name, count: String(ticket.files) }));
4028
+ } catch (err) {
4029
+ const e = err as Error & { status?: number };
4030
+ /* 409 is the server saying the selection held nothing this account may
4031
+ * read — a different sentence from "it failed", and the only one that
4032
+ * tells the reader what to do next. */
4033
+ flashToast(e.status === 409 ? t('toast.archive.empty') : e.message);
4034
+ emit('error', { message: e.message, context: { op: 'archive-download' } });
4035
+ }
4036
+ }
4037
+
4038
+ /**
4039
+ * tasi:m1 — the destination dialog came back with a folder.
4040
+ *
4041
+ * Everything the verb needs already exists: `transferItems` is the one gate
4042
+ * that moves and copies (cross-storage included) and owns the undo
4043
+ * registration, so this only says WHICH rows, WHERE, and WHICH intent.
4044
+ *
4045
+ * ⚠ `originWire` is the folder the rows came FROM, and it has to follow the
4046
+ * pane the selection belongs to — the same rule `actingInPane` states for
4047
+ * every other mutation.
4048
+ */
4049
+ async function onDestinationPicked(dest: string): Promise<void> {
4050
+ const targets = destPickerTargets.value;
4051
+ if (!dest || targets.length === 0) {
4052
+ showDestPicker.value = false;
4053
+ return;
4054
+ }
4055
+ const move = destPickerMode.value === 'move';
4056
+ const originWire = actingInPane()
4057
+ ? qualify(splitPaneRef.value?.getPath() ?? '')
4058
+ : qualify(currentPath.value);
4059
+ destPickerBusy.value = true;
4060
+ try {
4061
+ await transferItems(targets.map((n) => n.path), dest, originWire || undefined, move ? 'move' : 'copy');
4062
+ /* ⚠ `transferItems` has already flashed "queued". This REPLACES it rather
4063
+ * than stacking on it — there is one toast slot (`showToast`), and the
4064
+ * useful half of the sentence is the destination, which "queued" does not
4065
+ * carry. Do not add a second call expecting two messages; you would only
4066
+ * be choosing which one nobody reads. */
4067
+ const name = labelOfWire(dest, dest);
4068
+ flashToast(move ? t('toast.moved_to', { name }) : t('toast.copied_to', { name }));
4069
+ if (move) {
4070
+ if (actingInPane()) splitSelection.clear();
4071
+ else selection.clear();
4072
+ }
4073
+ } finally {
4074
+ destPickerBusy.value = false;
4075
+ showDestPicker.value = false;
4076
+ }
4077
+ }
4078
+
2829
4079
  // ------- Modals -------
2830
4080
 
4081
+ /* belge:n1 — after creating it, OPEN it. Creating a file and leaving the
4082
+ * person looking at a listing is half the feature. */
4083
+ async function onDocumentCreated(file: { path: string; name: string; ext: string }) {
4084
+ showNewDocument.value = false;
4085
+ const dir = file.path.slice(0, file.path.lastIndexOf('/'));
4086
+ if (dir && dir !== qualify(currentPath.value)) await load(dir);
4087
+ else await load();
4088
+ const node =
4089
+ files.value.find((n) => n.path === file.path) ??
4090
+ ({ type: 'file', path: file.path, basename: file.name, extension: file.ext } as unknown as FileNode);
4091
+ previewTarget.value = node;
4092
+ // ⚠ NOT previewModeForExt: that sends office types to 'view', which is right
4093
+ // for a peek at somebody else's file and wrong for the one you just made.
4094
+ previewMode.value = permCanEdit((node.perm as string) ?? dirPerm.value) ? 'edit' : 'view';
4095
+ showPreview.value = true;
4096
+ emit('file-opened', { path: node.path, basename: node.basename });
4097
+ void markRecent(node);
4098
+ }
4099
+
2831
4100
  async function submitNewFolder(name: string) {
2832
4101
  const inPane = mutationInPane.value; /* ui-fix — new folder in the side pane */
2833
4102
  try {
@@ -2916,13 +4185,7 @@ async function confirmDelete() {
2916
4185
  else selection.clear();
2917
4186
  } catch (err) {
2918
4187
  emit('error', { message: (err as Error).message, context: { op: 'delete' } });
2919
- }
2920
- }
2921
-
2922
- function openShare(n: FileNode) {
2923
- shareTarget.value = n;
2924
- activeShare.value = null;
2925
- showShare.value = true;
4188
+ }
2926
4189
  }
2927
4190
 
2928
4191
  function openConvert(n: FileNode) {
@@ -2935,32 +4198,6 @@ function onConvertDone(name: string) {
2935
4198
  void load();
2936
4199
  }
2937
4200
 
2938
- async function submitShare(payload: {
2939
- password: boolean;
2940
- expires_at: string | null;
2941
- max_downloads: number | null;
2942
- }) {
2943
- const target = shareTarget.value;
2944
- if (!target) return;
2945
- try {
2946
- const { share } = await api.createShare({
2947
- path: target.path, // qualified `<adapter>://<rel>`
2948
- password: payload.password,
2949
- expires_at: payload.expires_at,
2950
- max_downloads: payload.max_downloads,
2951
- });
2952
- activeShare.value = share;
2953
- emit('share-created', { path: target.path, url: share.url, pin: share.password_pin ?? null });
2954
- } catch (err) {
2955
- emit('error', { message: (err as Error).message, context: { op: 'share' } });
2956
- }
2957
- }
2958
-
2959
- function closeShare() {
2960
- showShare.value = false;
2961
- shareTarget.value = null;
2962
- activeShare.value = null;
2963
- }
2964
4201
 
2965
4202
  // ------- Upload -------
2966
4203
 
@@ -3325,19 +4562,49 @@ const clippedPaths = computed<Set<string>>(() => {
3325
4562
 
3326
4563
  const FE_DND_MIME = 'application/x-brf-files';
3327
4564
 
3328
- function onItemDragStart(node: FileNode, ev: DragEvent) {
4565
+ /**
4566
+ * pane:p1 — a drag started in one of the panes.
4567
+ *
4568
+ * `FilePane` has already written the payload both panes must agree on: the
4569
+ * internal MIME, the ORIGIN directory (which is what lets a cross-pane move be
4570
+ * undone) and the plain-text fallback. This is the part only the host knows —
4571
+ * the desktop shell's OS drag and the browser's own `DownloadURL` hand-off —
4572
+ * and wiring it for BOTH panes rather than only the left one is a capability
4573
+ * the right-hand pane gains simply by being the same pane.
4574
+ *
4575
+ * ⚠ `pane` decides two things and nothing else: whose selection is being
4576
+ * dragged, and which directory the drag says it came from. Everything below
4577
+ * that point is identical, which is the reason there is one function.
4578
+ */
4579
+ /**
4580
+ * pane:p1 — a row was clicked in one of the panes. Both panes reach the SAME
4581
+ * Ctrl/Shift semantics (`useSelection.click`), which is the point: the
4582
+ * right-hand pane used to keep a plain Set in which Shift behaved as Ctrl, so
4583
+ * a range-select worked on one side of the split and not the other.
4584
+ */
4585
+ function onPaneClickRow(
4586
+ pane: 'main' | 'split',
4587
+ n: FileNode,
4588
+ mod: { ctrl: boolean; shift: boolean },
4589
+ ) {
4590
+ (pane === 'split' ? splitSelection : selection).click(n.path, mod);
4591
+ }
4592
+
4593
+ function onPaneItemDragStart(pane: 'main' | 'split', node: FileNode, ev: DragEvent) {
3329
4594
  if (!ev.dataTransfer) return;
3330
4595
  if (node.basename === '.trash') {
3331
4596
  ev.preventDefault();
3332
4597
  return;
3333
4598
  }
3334
- if (!selection.has(node.path)) {
3335
- selection.click(node.path);
3336
- }
3337
- const items = selection.nodes.value
4599
+ const sel = pane === 'split' ? splitSelection.nodes.value : selection.nodes.value;
4600
+ const dirWire =
4601
+ pane === 'split' ? qualify(splitPaneRef.value?.getPath() ?? '') : qualify(currentPath.value);
4602
+ const chosen = sel.some((n) => n.path === node.path) ? sel : [node];
4603
+ const items = chosen
3338
4604
  .filter((n) => !clippedPaths.value.has(n.path))
3339
4605
  .filter((n) => n.basename !== '.trash')
3340
4606
  .map((n) => ({ path: n.path, basename: n.basename, type: n.type })); // qualified
4607
+ if (items.length === 0) return;
3341
4608
 
3342
4609
  /* wiring:f1 — drag-out (to the desktop / another application).
3343
4610
  When a shell is present the drag is ALWAYS an OS drag: folders and
@@ -3348,22 +4615,18 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
3348
4615
  the app the drag is still a server-side move — the payload stays with us —
3349
4616
  and the shell is told to "give up" so it doesn't watch the drives for
3350
4617
  nothing. */
3351
- if (dragOut.value && items.length > 0) {
4618
+ if (dragOut.value) {
3352
4619
  ev.preventDefault();
3353
- beginNativeDrag(items, qualify(currentPath.value));
4620
+ beginNativeDrag(items, dirWire);
3354
4621
  void Promise.resolve(dragOut.value.start(items)).catch((err) => {
3355
4622
  endNativeDrag();
3356
4623
  cancelShellDrag();
3357
4624
  emit('error', { message: (err as Error).message, context: { op: 'drag-out' } });
3358
4625
  });
4626
+ void prepareDragOut(items);
3359
4627
  return;
3360
4628
  }
3361
4629
 
3362
- ev.dataTransfer.setData(FE_DND_MIME, JSON.stringify(items));
3363
- ev.dataTransfer.setData(FE_DND_SRC_MIME, qualify(currentPath.value)); /* wiring:d1 — cross-pane origin stamp */
3364
- ev.dataTransfer.setData('text/plain', items.map((i) => i.path).join('\n'));
3365
- ev.dataTransfer.effectAllowed = 'move';
3366
-
3367
4630
  /* Single file + a cookie session: the browser's own download path
3368
4631
  (DownloadURL) fetches the file onto the desktop at drop time; no
3369
4632
  preparation is needed at all. In a bearer-token setup (the desktop app)
@@ -3373,8 +4636,6 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
3373
4636
  const payload = downloadUrlPayload(items[0], api.downloadUrl(items[0].path), node.mime_type);
3374
4637
  if (payload) ev.dataTransfer.setData('DownloadURL', payload);
3375
4638
  }
3376
-
3377
- if (dragOut.value && items.length > 0) void prepareDragOut(items);
3378
4639
  }
3379
4640
 
3380
4641
  /* === wiring:f1 — drag-out preparation ===
@@ -3448,19 +4709,24 @@ async function prepareDragOut(items: DragItem[], quiet = false): Promise<void> {
3448
4709
  async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: string, originOverride?: string): Promise<void> {
3449
4710
  try {
3450
4711
  const originWire = originOverride ?? qualify(currentPath.value); /* wiring:d1 — the real source folder for a drag coming from the split pane */
4712
+ const collides = await movedNamesCollide(sources, targetDir);
3451
4713
  if (api.endpoints.moveAsync) {
3452
4714
  const { op } = await api.moveAsync(sources, targetDir, originWire);
3453
- registerMoveUndo(op.id, sources, targetDir, originWire);
4715
+ registerMoveUndo(op.id, sources, targetDir, originWire, collides);
3454
4716
  pendingOps.register(op);
3455
- flashToast(t('split.move_queued'));
4717
+ flashToast(collides ? t('toast.move_kept_both') : t('split.move_queued'));
3456
4718
  } else {
3457
4719
  await api.move(originWire, sources, targetDir);
3458
4720
  await load();
3459
- // Sync move (no async endpoint): offer the reverse move right away.
3460
- const movedPaths = sources.map((p) => wireJoin(targetDir, wireBasename(p)));
3461
- undoToast(t('toast.moved'), async () => {
3462
- await api.move(targetDir, movedPaths, originWire);
3463
- });
4721
+ if (collides) {
4722
+ flashToast(t('toast.move_kept_both'));
4723
+ } else {
4724
+ // Sync move (no async endpoint): offer the reverse move right away.
4725
+ const movedPaths = sources.map((p) => wireJoin(targetDir, wireBasename(p)));
4726
+ undoToast(t('toast.moved'), async () => {
4727
+ await api.move(targetDir, movedPaths, originWire);
4728
+ });
4729
+ }
3464
4730
  }
3465
4731
  selection.clear();
3466
4732
  } catch (err) {
@@ -3856,12 +5122,34 @@ onMounted(() => {
3856
5122
  });
3857
5123
 
3858
5124
  function applyTabLocation(tb: TabState) {
3859
- if (tb.viewMode && tb.viewMode !== viewMode.value) viewMode.value = tb.viewMode;
5125
+ /* tablo:t1 the tab's own remembered view mode is applied only while
5126
+ * per-folder memory is OFF.
5127
+ *
5128
+ * ⚠⚠ Two things remembering the same fact is two things that disagree, and
5129
+ * this pair disagreed in a way nothing would have caught: open one folder in
5130
+ * two tabs, set it to grid in the first, switch to the second, and the second
5131
+ * restores the LIST it happened to be showing — for the same folder, whose
5132
+ * remembered view is grid. Measured, 2026-09-13. The applier below could not
5133
+ * correct it either, because it fires on a change of FOLDER and the folder
5134
+ * did not change; only the tab did.
5135
+ *
5136
+ * With the memory on, the folder is the authority and a tab is just a window
5137
+ * onto one, so the tab's copy is redundant and is ignored. With it off,
5138
+ * nothing here changes at all and tabs keep exactly the behaviour they had.
5139
+ */
5140
+ if (!folderMemoryEnabled() && tb.viewMode && tb.viewMode !== viewMode.value) {
5141
+ viewMode.value = tb.viewMode;
5142
+ }
5143
+ /* ⚠ AFTER the load, not before. `load()` is async, so `currentPath` — and
5144
+ * with it `currentFolderKey` — only moves once it settles; re-asserting here
5145
+ * is also what covers the case above, where the key never changes and the
5146
+ * watcher therefore never runs. */
5147
+ const settle = () => applyFolderView(currentFolderKey.value);
3860
5148
  if (tb.path === '.trash') {
3861
- void loadTrash();
5149
+ void loadTrash().then(settle, settle);
3862
5150
  return;
3863
5151
  }
3864
- void load(tb.path);
5152
+ void load(tb.path).then(settle, settle);
3865
5153
  }
3866
5154
  function activateTab(id: string) {
3867
5155
  const tb = tabsApi.activate(id);
@@ -3927,19 +5215,106 @@ onBeforeUnmount(() => {
3927
5215
 
3928
5216
  // ---- split (per-tab secondary pane) --------------------------------
3929
5217
 
3930
- const splitPaneRef = ref<InstanceType<typeof SecondaryPane> | null>(null);
3931
- // Split is disabled in narrow mode (the state is kept and comes back on widen).
3932
- const splitVisible = computed(
3933
- () => !!activeSplit.value && !isNarrow.value && !simpleUi.value /* gezinti:g1 */,
5218
+ const mainPaneRef = ref<InstanceType<typeof FilePane> | null>(null);
5219
+ const splitPaneRef = ref<InstanceType<typeof FilePane> | null>(null);
5220
+
5221
+ /* pane:p1 the split pane's half of the state the HOST has to hold.
5222
+ *
5223
+ * ⚠⚠ Two instances of ONE composable, not two implementations. The explorer
5224
+ * routes cut/copy/paste, the context menu, the inspector, the toolbar's count
5225
+ * and every keyboard shortcut through "the active pane's selection", so it has
5226
+ * to hold both — but `useSelection` is written once and this is the second
5227
+ * call to it, exactly as `lib/fileFilters` is written once and this is the
5228
+ * second filter object. What used to be here instead was a SECOND selection
5229
+ * model living inside `SecondaryPane`, in which Shift behaved as Ctrl because
5230
+ * it kept a plain Set with no range anchor. */
5231
+ const splitDisplayOrder = ref<FileNode[]>([]);
5232
+ const splitSelection = useSelection(() =>
5233
+ splitDisplayOrder.value.length ? splitDisplayOrder.value : (splitPaneRef.value?.visibleNodes() ?? []),
5234
+ );
5235
+ const splitFilters = ref<DriveFilters>({ ...EMPTY_FILTERS });
5236
+
5237
+ /** The split pane's folder, in the key `lib/viewPrefs` remembers folders under
5238
+ * — so the column menu's two per-folder rows tell the truth on the right as
5239
+ * well as on the left.
5240
+ *
5241
+ * ⚠⚠ READ-ONLY on this side, and that is a decision. The memory answers "how
5242
+ * did I leave this folder", and a folder is left in ONE state; two panes both
5243
+ * writing it would be the stale-snapshot race that already bit the tab strip
5244
+ * once today, with a second racer. So: BOTH panes read a folder's arrangement
5245
+ * when they arrive in it (the split pane's sort store is seeded from the
5246
+ * global default and this key is what its column menu operates on), and only
5247
+ * the main pane — the one the window's tab is about, the one whose view mode
5248
+ * the tab records — writes it back. Nothing re-applies memory to a pane that
5249
+ * is sitting still, so a choice made in one pane can never yank the other
5250
+ * pane's view out from under it. */
5251
+ const splitFolderKey = computed(() => {
5252
+ if (!folderMemoryOn.value) return '';
5253
+ const path = String(splitPaneRef.value?.getPath() ?? '').replace(/^\/+|\/+$/g, '');
5254
+ if (!path) return '';
5255
+ const [first, ...rest] = path.split('/');
5256
+ const st = (props.config.storages ?? []).find((sto) => sto.name === first);
5257
+ return makeFolderKey(st?.uid || first, rest.join('/'));
5258
+ });
5259
+
5260
+ /* pane:p1 — the row above BOTH panes. It survives the tabs being switched off
5261
+ * (the `simple` profile) because the split and details toggles live in it too;
5262
+ * `hideTabs` empties it without removing it. */
5263
+ const paneRowVisible = computed(
5264
+ () => tabsVisible.value || infoPanelToggle.value || splitOffered.value,
3934
5265
  );
3935
5266
 
5267
+ /**
5268
+ * pane:p1 — the states the WINDOW draws INSTEAD of a listing.
5269
+ *
5270
+ * Home, a dead deep link and the encrypted lock screen are not things that can
5271
+ * happen to a pane — they are things that have happened to the explorer — so
5272
+ * they arrive in `FilePane`'s `body` slot and this says which. Everything else
5273
+ * in the old chain (the skeleton, the failed listing, the filtered-empty
5274
+ * state, the listing itself) IS pane state and now lives in the pane, once,
5275
+ * for both halves.
5276
+ *
5277
+ * ⚠ The two `return ''`s are load-bearing: they hand the turn back to the
5278
+ * pane's own skeleton and error states in exactly the order the chain had, so
5279
+ * a folder that is still loading does not flash its lock screen and a failed
5280
+ * listing still gets the retry state rather than "not found".
5281
+ */
5282
+ const hostBodyState = computed<'' | 'home' | 'notfound' | 'locked'>(() => {
5283
+ if (navView.value === 'home') return 'home';
5284
+ if (loading.value && files.value.length === 0) return '';
5285
+ if (notFoundPath.value) return 'notfound';
5286
+ if (loadError.value && files.value.length === 0) return '';
5287
+ if (e2eLocked.value) return 'locked';
5288
+ return '';
5289
+ });
5290
+ /**
5291
+ * Can a second pane be OPENED here at all?
5292
+ *
5293
+ * ⚠⚠ One answer for every door to the split, and it is not `!isNarrow`. The
5294
+ * tab strip's toggle and the command palette's row were both handed
5295
+ * `!isNarrow` and nothing else, while the pane below was gated on the
5296
+ * `simple` profile as well — so under `ui-profile="simple"` the button was
5297
+ * drawn, a click recorded a split on the tab and turned the button PRESSED,
5298
+ * and no pane ever appeared (measured 2026-09-14 at 1440: one pane before the
5299
+ * click, one after, `aria-pressed="true"`). A control that lies about its own
5300
+ * state is worse than a missing one. The state it wrote is kept, so switching
5301
+ * the profile back would suddenly split a tab nobody remembers splitting —
5302
+ * which is why `toggleSplit` refuses to open one here too, not only the
5303
+ * button.
5304
+ *
5305
+ * Narrow mode keeps the state and brings it back on widen; the simple profile
5306
+ * offers no split at all (`lib/uiProfile`).
5307
+ */
5308
+ const splitOffered = computed(() => !isNarrow.value && !simpleUi.value);
5309
+ const splitVisible = computed(() => !!activeSplit.value && splitOffered.value /* gezinti:g1 */);
5310
+
3936
5311
  function toggleSplit() {
3937
5312
  if (activeSplit.value) {
3938
5313
  tabsApi.setSplit(null);
3939
5314
  activePane.value = 'main';
3940
5315
  return;
3941
5316
  }
3942
- if (isNarrow.value) return;
5317
+ if (!splitOffered.value) return;
3943
5318
  tabsApi.setSplit({ path: currentPath.value ?? '', viewMode: viewMode.value });
3944
5319
  }
3945
5320
  function closeSplit() {
@@ -3948,7 +5323,36 @@ function closeSplit() {
3948
5323
  }
3949
5324
  function onPaneNavigate(p: string) {
3950
5325
  tabsApi.setSplit({ ...(activeSplit.value ?? {}), path: p });
5326
+ /* A navigation is a new folder: the chips the person set for the folder they
5327
+ just left must not silently keep narrowing the one they arrived in. Same
5328
+ rule the main pane's own watcher applies. */
5329
+ if (filtersActive(splitFilters.value)) splitFilters.value = { ...EMPTY_FILTERS };
5330
+ splitSelection.clear();
5331
+ splitDisplayOrder.value = [];
3951
5332
  }
5333
+
5334
+ /**
5335
+ * pane:p1 — a row was opened (double-click, Enter) in one of the panes.
5336
+ *
5337
+ * A FOLDER opens in the pane it was opened from — that is the whole point of a
5338
+ * second pane. Everything else is the window's business (the preview modal,
5339
+ * the encrypted-blob path, `markRecent`) and goes to the one `openNode` both
5340
+ * panes have always been entitled to. The right-hand pane used to have no path
5341
+ * to it at all, so double-clicking a file there did nothing.
5342
+ */
5343
+ function onPaneOpen(pane: 'main' | 'split', n: FileNode) {
5344
+ if (pane === 'split' && n.type === 'dir' && n.basename !== '.trash') {
5345
+ void splitPaneRef.value?.loadFolder(
5346
+ /* A multi-storage drive row's path is already the wire form for that
5347
+ storage's root; a real row needs converting. The predicate comes from
5348
+ `lib/fileIcons`, which is that concept's one home. */
5349
+ iconFamilyFor(n) === 'storage' ? n.path : paneToUser(n.path),
5350
+ );
5351
+ return;
5352
+ }
5353
+ openNode(n);
5354
+ }
5355
+
3952
5356
  /* ui-fix — when the trash row is opened from the side pane: the trash view
3953
5357
  * (with its restore actions) belongs to the main pane → activate the main
3954
5358
  * pane and open it there. */
@@ -3984,6 +5388,130 @@ watch(splitVisible, (v) => {
3984
5388
  });
3985
5389
  const paneIsActive = computed(() => activePane.value === 'split' && splitVisible.value);
3986
5390
  const mainPaneFocus = computed(() => splitVisible.value && activePane.value === 'main');
5391
+ /** The `data-pane` of the half that owns `activeTargets()`. The selection bar
5392
+ * is teleported into THAT pane, so this is what tells the toolbar where. */
5393
+ const activePaneId = computed(() => (paneIsActive.value ? 'split' : 'main'));
5394
+
5395
+ /* === koru:k1 + pane:p1 — THE DETAILS PANEL FOLLOWS THE FOCUSED PANE =======
5396
+ *
5397
+ * ⚠⚠ It did not, and the owner reported it, 2026-09-13: "Yanda açılan info
5398
+ * panele sadece ana pane üzerinde tıklanmış ya da bulunduğumuz yerin infosu
5399
+ * geliyor. Onun dışında öteki pane'de tıkladığımız yerlerin infosu hiç
5400
+ * gelmiyor." The panel read `selection.nodes` — the MAIN pane's selection —
5401
+ * so clicking a row in the right-hand half changed the highlight, changed the
5402
+ * selection bar (which had just been taught this rule) and left the panel
5403
+ * describing something in the other half entirely.
5404
+ *
5405
+ * ⚠ The same source as the selection bar, deliberately: `activeTargets()`.
5406
+ * That function is already the one answer to "what is selected right now", and
5407
+ * the whole reason it exists is that the toolbar used to have a second one.
5408
+ * A third, here, would be the same bug a month later.
5409
+ *
5410
+ * ⚠ WITH NO SELECTION the panel describes the focused pane's FOLDER, which is
5411
+ * the behaviour it already had for the main pane and the only honest one: the
5412
+ * head then reads the place you are standing in. So clicking into the split
5413
+ * pane's empty background moves the panel to that pane's folder rather than
5414
+ * leaving it on the other half's — which is the complaint, one level up.
5415
+ */
5416
+ /**
5417
+ * ⚠⚠ IT HOLDS THE LAST SELECTED THING. Owner's ruling, 2026-09-13, asked and
5418
+ * answered: "son seçilen şeyi tutsun."
5419
+ *
5420
+ * So moving the keyboard to a pane with nothing ticked does NOT empty the panel
5421
+ * and does NOT fall back to that pane's folder — it goes on describing whatever
5422
+ * was selected last, until something else is selected. That is the difference
5423
+ * between a panel you can read while you work in the other half and a panel
5424
+ * that blanks the moment you click away from what you were reading about.
5425
+ *
5426
+ * ⚠ The two consequences of holding, handled below rather than left implicit:
5427
+ * 1. the held item can live in the pane you are NOT looking at, so the panel
5428
+ * is TOLD it is holding and where the item is (`heldIn`), and says so;
5429
+ * 2. a held item can stop existing — deleted, moved, or its pane closed — and
5430
+ * a panel describing a file that is gone is worse than an empty one, so
5431
+ * `heldValid` drops it the moment its own pane's listing no longer has it.
5432
+ */
5433
+ const heldSelection = ref<{ nodes: FileNode[]; pane: 'main' | 'split'; path: string } | null>(null);
5434
+
5435
+ /** The focused pane's location, user-path form. */
5436
+ const activePanePath = computed(() =>
5437
+ paneIsActive.value ? (splitPaneRef.value?.getPath() ?? '') : (currentPath.value ?? ''),
5438
+ );
5439
+ function panePathOf(pane: 'main' | 'split'): string {
5440
+ return pane === 'split' ? (splitPaneRef.value?.getPath() ?? '') : (currentPath.value ?? '');
5441
+ }
5442
+ /** The paths a pane's folder HOLDS — before the filter row narrows them. ⚠ The
5443
+ * unfiltered set on both sides: "is this row still there" and "is this row
5444
+ * currently drawn" are different questions, and answering the first with the
5445
+ * second would make typing in the filter box drop the held item as if the file
5446
+ * had been deleted. */
5447
+ function panePathsOf(pane: 'main' | 'split'): string[] {
5448
+ return pane === 'split'
5449
+ ? (splitPaneRef.value?.rowPaths() ?? [])
5450
+ : files.value.map((n) => n.path);
5451
+ }
5452
+
5453
+ /* Capture: any non-empty selection, in either pane, becomes the held one. */
5454
+ watch(
5455
+ () => activeTargets(),
5456
+ (nodes) => {
5457
+ if (nodes.length) {
5458
+ heldSelection.value = {
5459
+ nodes,
5460
+ pane: activePaneId.value as 'main' | 'split',
5461
+ path: activePanePath.value,
5462
+ };
5463
+ }
5464
+ },
5465
+ { deep: false },
5466
+ );
5467
+
5468
+ /**
5469
+ * Is the held item still a real thing?
5470
+ *
5471
+ * ⚠ Only checked while its own pane is still showing the folder it was held
5472
+ * from. Walking away from a folder is not a deletion — "last selected" has to
5473
+ * survive a navigation or it is not a hold at all — but standing in the same
5474
+ * folder with the row gone IS one, and that is the case that must not be
5475
+ * described. A closed split pane takes its held item with it.
5476
+ */
5477
+ const heldValid = computed<boolean>(() => {
5478
+ const h = heldSelection.value;
5479
+ if (!h || !h.nodes.length) return false;
5480
+ if (h.pane === 'split' && !splitVisible.value) return false;
5481
+ if (panePathOf(h.pane) !== h.path) return true; // navigated away — still held
5482
+ const here = new Set(panePathsOf(h.pane));
5483
+ return h.nodes.some((n) => here.has(n.path));
5484
+ });
5485
+ watch(heldValid, (ok) => {
5486
+ if (!ok) heldSelection.value = null;
5487
+ });
5488
+
5489
+ /** True while the panel is describing the HELD item rather than a live
5490
+ * selection — i.e. the focused pane has nothing ticked. */
5491
+ const inspectorHeld = computed(() => activeTargets().length === 0 && heldValid.value);
5492
+ const inspectorNodes = computed<FileNode[]>(() => {
5493
+ const live = activeTargets();
5494
+ if (live.length) return live;
5495
+ return heldValid.value ? (heldSelection.value?.nodes ?? []) : [];
5496
+ });
5497
+ /** Where the held item lives — named, so "this is not what is selected in front
5498
+ * of you" is never a guess. Empty while a live selection is shown. */
5499
+ const inspectorHeldIn = computed(() =>
5500
+ inspectorHeld.value ? folderLabelOf(heldSelection.value?.path ?? '') : '',
5501
+ );
5502
+
5503
+ /** Folder summary label for the truly-empty state — of the FOCUSED pane. */
5504
+ const inspectorDirLabel = computed(() => folderLabelOf(activePanePath.value));
5505
+ /** How many rows that folder holds. `files` IS the main pane's row array, so
5506
+ * the two branches are the same quantity measured on the two panes. */
5507
+ const inspectorDirCount = computed(() =>
5508
+ paneIsActive.value ? (splitPaneRef.value?.rowCount() ?? 0) : files.value.length,
5509
+ );
5510
+ /** RBAC level of that folder. The main pane's comes off the host's own load;
5511
+ * the split pane reads it from the response to its own `index`. */
5512
+ const inspectorDirPerm = computed(() =>
5513
+ paneIsActive.value ? (splitPaneRef.value?.dirPerm() ?? '') : dirPerm.value,
5514
+ );
3987
5515
 
3988
5516
  // Pane helpers — always wrap the main pane's existing converters.
3989
5517
  function paneToUser(wire: string): string {
@@ -4000,13 +5528,13 @@ function paneClamp(p: string): string {
4000
5528
  // selection and paste lands in the pane's folder. The state is SHARED with the
4001
5529
  // main pane's — so cut-and-paste between panes works for free.
4002
5530
  function paneCut() {
4003
- const nodes = splitPaneRef.value?.selectedNodes() ?? [];
5531
+ const nodes = splitSelection.nodes.value;
4004
5532
  if (nodes.length === 0) return;
4005
5533
  clipboard.value = { mode: 'cut', items: nodes, sourcePath: splitPaneRef.value?.getPath() ?? '' };
4006
5534
  flashToast(t('toast.cut'));
4007
5535
  }
4008
5536
  function paneCopy() {
4009
- const nodes = splitPaneRef.value?.selectedNodes() ?? [];
5537
+ const nodes = splitSelection.nodes.value;
4010
5538
  if (nodes.length === 0) return;
4011
5539
  clipboard.value = { mode: 'copy', items: nodes, sourcePath: splitPaneRef.value?.getPath() ?? '' };
4012
5540
  flashToast(t('toast.copied'));
@@ -4648,21 +6176,14 @@ function closeRecoveryKey() {
4648
6176
  @drop="onDropUpload"
4649
6177
  @contextmenu="onContextCanvas"
4650
6178
  >
4651
- <!-- wiring:d1 — tab strip: not rendered at all on a SINGLE tab (embeds stay pixel-identical) -->
4652
- <TabBar
4653
- v-if="tabsVisible"
4654
- :tabs="tabItems"
4655
- :active-id="tabsActiveId"
4656
- :locale="locale"
4657
- :split-enabled="!isNarrow"
4658
- :split-active="!!activeSplit"
4659
- @select="activateTab"
4660
- @close="closeTabById"
4661
- @new="newTabHere"
4662
- @reorder="(from: number, to: number) => tabsApi.move(from, to)"
4663
- @toggle-split="toggleSplit"
4664
- />
4665
- <!-- /wiring:d1 -->
6179
+ <!-- wiring:d1 — the tab strip used to be the explorer's first row, above
6180
+ the header and spanning the sidebar too.
6181
+ gorunum:v2-topbar — it then moved under the breadcrumb, INSIDE the
6182
+ left pane.
6183
+ gorunum:v5-panestack — and it is out again, one level up: a tab is a
6184
+ location the WINDOW is showing and the split happens within it, so the
6185
+ strip spans both panes and each pane carries only its own address.
6186
+ See `.fe__stack` below. -->
4666
6187
  <Toolbar
4667
6188
  ref="toolbarRef"
4668
6189
  :view-mode="displayedViewMode /* ui-fix — the active pane's mode */"
@@ -4670,6 +6191,13 @@ function closeRecoveryKey() {
4670
6191
  :trash-active="trashActive"
4671
6192
  :actions="toolbarActions"
4672
6193
  :selection-mode="selectionMode"
6194
+ :selection-count="activeTargets().length /* gorunum:v1 — the count the
6195
+ selection bar prints. Without it the toolbar counted the ticked rows by
6196
+ watching the DOM, which is a second source of truth for something the
6197
+ parent already knows.
6198
+ pane:p1 — and it is the ACTIVE pane's count, so the number and the
6199
+ bar's position can never describe two different panes. */"
6200
+ :selection-pane="activePaneId /* pane:p1 — which half the bar mounts into */"
4673
6201
  :paste-enabled="!!clipboard.mode"
4674
6202
  :convert-enabled="!!effectiveConvertUrl"
4675
6203
  :can-go-up="canGoUp"
@@ -4682,23 +6210,46 @@ function closeRecoveryKey() {
4682
6210
  :nav-open="navToggleOn /* gezinti:g1 */"
4683
6211
  :nav-enabled="sideNavEnabled /* gezinti:g1 */"
4684
6212
  :view-modes="allowedViewModes /* gezinti:g1 */"
4685
- :shell="driveShell ? 'drive' : 'classic' /* surucu:d1 */"
4686
6213
  :scope-label="driveScopeLabel /* surucu:d1 */"
6214
+ :brand-name="config.brand?.name /* gorunum:v3-shell */"
6215
+ :brand-mark-url="config.brand?.markUrl /* gorunum:v3-shell */"
6216
+ :search-escalates="navView === 'home' /* gorunum:v3-shell */"
4687
6217
  @open-palette="openPaletteWith /* surucu:d1 */"
4688
6218
  @toggle-inspector="toggleInspector /* koru:k1 */"
4689
6219
  @toggle-nav="toggleSideNav /* gezinti:g1 */"
4690
6220
  @open-theme="showThemeGallery = true /* wiring:c1 */"
4691
6221
  @update:view-mode="setDisplayedViewMode($event) /* ui-fix — to the active pane */"
4692
- @update:search-query="searchQuery = $event"
6222
+ @update:search-query="onToolbarSearch /* gorunum:v1-advsearch */"
6223
+ @open-advanced-search="openAdvancedSearch /* gorunum:v1-advsearch */"
4693
6224
  @update:density="density = $event"
4694
6225
  @open-shortcut-settings="showShortcutSettings = true /* wiring:c2 */"
6226
+ @open-timezone="showTimeZone = true /* zaman:z3 */"
4695
6227
  @new-folder="showNewFolder = true"
4696
6228
  @upload="triggerUpload"
4697
- @refresh="() => load()"
6229
+ @refresh="refreshAll /* gorunum:v2-topbar */"
4698
6230
  @go-up="goUp"
4699
6231
  @action="onToolbarAction"
4700
6232
  @open-recents="showRecents = true"
4701
- />
6233
+ >
6234
+ <!-- gorunum:v3-shell — the host's product mark, at the far left of the
6235
+ top bar beside the panel's collapse control. Passed straight
6236
+ through: this package has no branding of its own and must not grow
6237
+ any.
6238
+ ⚠⚠ `v-if="$slots.brand"` is load-bearing, not tidiness. Declaring
6239
+ this template unconditionally would hand Toolbar a `brand` slot on
6240
+ every mount — an EMPTY one for a host that filled nothing — and a
6241
+ slot that exists always is a slot whose fallback content never
6242
+ renders. That fallback is `config.brand`, and it is the only door a
6243
+ `<filex-explorer>` host has (slots do not reach a Vue custom
6244
+ element at all; see ExplorerConfig.brand for the measurement). So:
6245
+ slot when there is one, config otherwise. -->
6246
+ <template v-if="$slots.brand" #brand><slot name="brand"></slot></template>
6247
+ <!-- gorunum:v2-topbar — the host's account-level doors (admin panel,
6248
+ settings, sign out), passed straight through. The explorer knows
6249
+ nothing about them and must not: they are the EMBEDDER's chrome,
6250
+ and an embed with none renders an empty cluster. -->
6251
+ <template #header-actions><slot name="header-actions"></slot></template>
6252
+ </Toolbar>
4702
6253
 
4703
6254
  <!-- koru:k1 — fe__main lays the listing body and the inspector panel out
4704
6255
  as flex siblings (row). Without the inspector open it is visually
@@ -4726,14 +6277,15 @@ function closeRecoveryKey() {
4726
6277
  :show-identity-surfaces="identitySurfaces"
4727
6278
  :can-write="canWriteHere && !atVirtualRoot && !trashActive"
4728
6279
  :locale="locale"
4729
- :new-menu="driveShell /* surucu:d1 */"
4730
6280
  :can-request-files="canWriteHere && !atVirtualRoot && !trashActive && !navView /* surucu:d1 */"
6281
+ :can-new-document="canNewDocument && !trashActive && !navView /* belge:n1 */"
6282
+ @new-document="showNewDocument = true"
4731
6283
  :quota="quotaSnapshot /* surucu:d1 */"
4732
6284
  :theme="themeMode /* surucu:d1 — the teleported New menu leaves .fe */"
4733
6285
  @request-files="openFileRequest /* surucu:d1 */"
4734
6286
  @toggle="toggleSideNav"
4735
6287
  @close="closeNavDrawer"
4736
- @open-view="loadNavView"
6288
+ @open-view="openNavDest /* gorunum:v3-shell */"
4737
6289
  @open-tag="loadTagView"
4738
6290
  @open-storage="openNavStorage"
4739
6291
  @upload="triggerUpload"
@@ -4751,80 +6303,131 @@ function closeRecoveryKey() {
4751
6303
  :aria-label="t('sidenav.close')"
4752
6304
  @click="closeNavDrawer"
4753
6305
  ></button>
4754
- <!-- ui-fix the left pane's header (breadcrumb + status strips + body)
4755
- in one wrapper: in split mode this wrapper fits the left half, so the
4756
- breadcrumb spans its own pane rather than the whole page (symmetric
4757
- with SecondaryPane's own crumbs). The active-pane accent lives on
4758
- this wrapper too. -->
4759
- <div
4760
- class="fe__primary"
4761
- :class="{ 'fe-pane--focus': mainPaneFocus } /* wiring:d1 active-pane accent */"
4762
- >
4763
- <!-- surucu:d1 — the breadcrumb row. In the drive shell it also carries the
4764
- two controls that belong to a LISTING rather than to the app (the view
4765
- switcher and the details toggle), which is where the mockups put them
4766
- and where the header then has room for one wide search field.
4767
- Everywhere else the wrapper is `display: contents`, so the breadcrumb
4768
- is the same flex child of `.fe__primary` it has always been one
4769
- Breadcrumb, not a second copy that can drift. -->
4770
- <div :class="driveShell ? 'fe-subhead' : 'fe-subhead--plain'">
4771
- <Breadcrumb
4772
- :dirname="dirname"
4773
- :adapter="adapter"
4774
- :root-label="adapter"
6306
+ <!-- gorunum:v5-panestack / pane:p1 THE TAB STRIP BELONGS TO THE WINDOW,
6307
+ and BOTH halves below it are the SAME component.
6308
+
6309
+ Owner's decision, 2026-09-13: *"tab içinde split yapman lazım, dışında
6310
+ yapıyorsun"* a tab is a location the window is showing, and the split
6311
+ happens WITHIN that location. So: top bar, then the strip full width,
6312
+ then the panes. The strip used to render inside the left half, which
6313
+ made the left pane carry the window's chrome and the right one read as
6314
+ an afterthought.
6315
+
6316
+ And the second half of the same ruling: *"split pane ile gelen yeni
6317
+ pane aslında yandaki pane ile birebir olması lazım"*. There is now ONE
6318
+ `FilePane`, rendered twice. Everything a listing has the crumbs, the
6319
+ filter row, the sort control, the view switcher, the selection-bar
6320
+ slot, the states, the column menu is defined once, in that file, so
6321
+ a feature added to a pane cannot miss the other pane. -->
6322
+ <div class="fe__stack">
6323
+ <TabBar
6324
+ v-if="paneRowVisible"
6325
+ :tabs="tabItems"
6326
+ :active-id="tabsActiveId"
6327
+ :locale="locale"
6328
+ :hide-tabs="!tabsVisible /* gorunum:v5-panerow — the row stays for the
6329
+ split and details toggles even where tabs are not offered */"
6330
+ :split-enabled="splitOffered /* the pane's own gate — see splitOffered */"
6331
+ :split-active="!!activeSplit"
6332
+ :inspector-enabled="infoPanelToggle"
6333
+ :inspector-open="showInspector"
6334
+ @select="activateTab"
6335
+ @close="closeTabById"
6336
+ @new="newTabHere"
6337
+ @reorder="(from: number, to: number) => tabsApi.move(from, to)"
6338
+ @toggle-split="toggleSplit"
6339
+ @toggle-inspector="toggleInspector"
6340
+ />
6341
+ <div class="fe__panes">
6342
+
6343
+ <!-- pane:p1 — the main pane. HOST-DRIVEN (`self-driven` absent): its rows
6344
+ come from `load()`, which also answers a search, the trash, the
6345
+ panel's virtual views and an encrypted folder. Those are states the
6346
+ WINDOW is in, so the three of them that have no listing behind them
6347
+ arrive through the `body` slot and the rest through `empty`. -->
6348
+ <FilePane
6349
+ pane-id="main"
6350
+ ref="mainPaneRef"
6351
+ :api="api"
4775
6352
  :locale="locale"
4776
- :multi-storage-root="multiStorageRoot"
6353
+ :theme="themeMode"
6354
+ :focused="mainPaneFocus"
6355
+ :path="currentPath"
6356
+ :qualify="qualify"
6357
+ :to-user="paneToUser"
6358
+ :clamp="paneClamp"
4777
6359
  :root-path="rootPathProp"
6360
+ :floor="rootFloor"
6361
+ :multi-root="multiStorageRoot"
6362
+ :rows="files"
6363
+ :loading="loading"
6364
+ :error="loadError"
6365
+ :order="listingOrder"
6366
+ :view-mode="viewMode"
6367
+ :view-modes="allowedViewModes"
6368
+ :show-crumbs="navView !== 'home' /* Home has no address: its cards come
6369
+ from every folder in every storage, so a trail would have to name
6370
+ one */"
6371
+ :filter-mode="filterRowMode /* `show-filter-bar` is NOT passed: the row is
6372
+ drawn in every view now, so the default (true) is the answer and a
6373
+ prop repeating it would be a second place to forget. */"
6374
+ :find-label="navView === 'home' ? t('filter.find.home') : ''"
6375
+ :show-view-switcher="navView !== 'home'"
6376
+ :folder-key="currentFolderKey"
6377
+ :show-parent-path="!!searchQuery || crossFolderView"
6378
+ :clipped="clippedPaths"
6379
+ :extra-filters="advFilters"
6380
+ :can-write="canWriteHere && !trashActive"
6381
+ :can-paste="!!clipboard.mode"
6382
+ :selected="selection.selected.value"
6383
+ :filters="driveFilters"
6384
+ :thumb-src="thumbs.src"
6385
+ :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
6386
+ :starred-ids="starredIds"
6387
+ :star-enabled="identitySurfaces"
6388
+ :api-base="props.config.apiBase ?? ''"
6389
+ :auth-headers="() => buildAuthHeaders()"
6390
+ :auth-credentials="api.credentialsMode()"
6391
+ :e2e-active="e2eActive"
6392
+ :body-override="hostBodyState !== ''"
6393
+ @activate="setPaneMain"
4778
6394
  @navigate="onNavigate"
4779
- @copy-path="onCopyPath"
6395
+ @open="openNode"
6396
+ @open-trash="() => loadTrash()"
6397
+ @click-row="(n, m) => onPaneClickRow('main', n, m)"
6398
+ @context="(n: FileNode | null, ev: MouseEvent) => onPaneMenu('main', n, ev)"
6399
+ @clear-selection="selection.clear()"
6400
+ @display-order="(nodes: FileNode[]) => (displayOrder = nodes)"
6401
+ @item-drag-start="(n: FileNode, ev: DragEvent) => onPaneItemDragStart('main', n, ev)"
6402
+ @item-drop-into="onItemDropInto"
6403
+ @transfer="onPaneTransfer"
6404
+ @update:view-mode="(v: ViewMode) => (viewMode = v)"
6405
+ @update:filters="setDriveFilters"
4780
6406
  @crumb-context="onCrumbContext"
6407
+ @copy-path="onCopyPath"
4781
6408
  @crumb-drop="onCrumbDropInto"
4782
- />
4783
- <div v-if="driveShell" class="fe-subhead__actions">
4784
- <ViewSwitcher
4785
- :view-mode="displayedViewMode"
4786
- :locale="locale"
4787
- :modes="allowedViewModes"
4788
- @update:view-mode="setDisplayedViewMode($event)"
4789
- />
4790
- <button
4791
- v-if="infoPanelToggle"
4792
- type="button"
4793
- class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
4794
- :class="{ 'is-active': showInspector }"
4795
- :aria-pressed="showInspector"
4796
- :title="t('toolbar.inspector')"
4797
- :aria-label="t('toolbar.inspector')"
4798
- data-testid="subhead-inspector"
4799
- @click="toggleInspector"
4800
- >
4801
- <svg
4802
- class="fe-ficon"
4803
- viewBox="0 0 24 24"
4804
- fill="none"
4805
- stroke="currentColor"
4806
- stroke-width="1.8"
4807
- stroke-linecap="round"
4808
- aria-hidden="true"
4809
- focusable="false"
4810
- >
4811
- <circle cx="12" cy="12" r="9" />
4812
- <path d="M12 11v5" />
4813
- <circle cx="12" cy="7.6" r="1" fill="currentColor" stroke="none" />
4814
- </svg>
4815
- </button>
4816
- </div>
4817
- </div>
4818
- <FilterBar
4819
- v-if="driveShell && !atVirtualRoot"
4820
- :value="driveFilters"
4821
- :locale="locale"
4822
- :theme="themeMode"
4823
- :shown="displayFiles.length"
4824
- :total="files.length"
4825
- @update:value="setDriveFilters"
4826
- />
4827
-
6409
+ @new-folder="showNewFolder = true"
6410
+ @upload="triggerUpload"
6411
+ @paste="onToolbarAction('paste') /* surucu:d1-actions — through the SAME
6412
+ handler the right-click menu and the toolbar use */"
6413
+ @select-all="selection.selectAll()"
6414
+ @clear-filters="clearDriveFilters"
6415
+ @star-change="onStarChange"
6416
+ @retry="retryLoad"
6417
+ >
6418
+ <!-- ⚠ NO `#heading` for Home, and the empty address row it used to live
6419
+ on is not drawn either (FilePane's own guard). Owner, 2026-09-13:
6420
+ "ver sayfa içinde salak bir Home yazısı var, onu kaldıralım."
6421
+ He is right and it is the same rule the tab strip already follows:
6422
+ the panel's Home row is highlighted, the tab says Home and the
6423
+ address bar says `#.home` — a fourth "Home", in 18px type, over
6424
+ three sections that name themselves, was the page introducing itself
6425
+ to somebody who had just clicked its name. The SECTION headings
6426
+ (Storages / Recent / Starred) stay: those name the blocks, not the
6427
+ page. `home.title` is still the tab's and the panel row's word. -->
6428
+
6429
+ <!-- Strips that describe the WINDOW's state rather than the listing. -->
6430
+ <template #banners>
4828
6431
  <!-- Live presence: who else is viewing this folder (empty → nothing shown).
4829
6432
  When the live socket is unavailable the same strip carries a small
4830
6433
  degraded-connection badge instead (presence is empty in fallback);
@@ -4928,7 +6531,8 @@ function closeRecoveryKey() {
4928
6531
  </div>
4929
6532
  </div>
4930
6533
  <div v-if="e2eUnlocked" class="fe-e2e-strip" role="status">
4931
- <span class="fe-e2e-strip__icon" aria-hidden="true">🔒</span>
6534
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
6535
+ <span class="fe-e2e-strip__icon" aria-hidden="true" v-html="actionIconSvg('lock')"></span>
4932
6536
  <span class="fe-e2e-strip__label">{{ t('e2e.strip.label') }}</span>
4933
6537
  <!-- The way back for somebody who declined. Quiet, but present: a
4934
6538
  refusal that could not be reversed without deleting the folder
@@ -4947,34 +6551,60 @@ function closeRecoveryKey() {
4947
6551
  </div>
4948
6552
  <!-- /wiring:e2 -->
4949
6553
 
4950
- <div
4951
- class="fe__body"
4952
- @pointerdown.capture="setPaneMain() /* wiring:d1 */"
4953
- @click.self="selection.clear()"
4954
- >
4955
- <!-- Initial load: skeleton ghosts (view-mode aware) instead of an
4956
- empty/"no files" flash. Only when there's nothing yet — navigation
4957
- keeps the current list, exactly as before. -->
4958
- <div v-if="loading && files.length === 0" class="fe__skeleton" role="status">
4959
- <span class="fe-sr-only">{{ t('loading') }}</span>
4960
- <div v-if="viewMode !== 'list' /* wiring:d2 — the gallery uses the grid skeleton too */" class="fe-skel-grid" aria-hidden="true">
4961
- <div v-for="i in 8" :key="i" class="fe-skel-card">
4962
- <div class="fe-skel fe-skel--thumb"></div>
4963
- <div class="fe-skel fe-skel--label"></div>
4964
- </div>
4965
- </div>
4966
- <div v-else class="fe-skel-list" aria-hidden="true">
4967
- <div v-for="i in 8" :key="i" class="fe-skel-row">
4968
- <div class="fe-skel fe-skel--icon"></div>
4969
- <div class="fe-skel fe-skel--name"></div>
4970
- <div class="fe-skel fe-skel--size"></div>
4971
- <div class="fe-skel fe-skel--date"></div>
4972
- </div>
4973
- </div>
4974
- </div>
6554
+ <!-- tablo:t1 — the trash banner: what the trash IS on the left, the one
6555
+ irreversible action on the right. OUTSIDE `fe__body` so it sits above
6556
+ the listing AND above the centred empty state, which is where the
6557
+ reference draws it and the only place it reads as a property of the
6558
+ view rather than of the rows. -->
6559
+ <div v-if="trashMode && !loading" class="fe-trashbar">
6560
+ <p class="fe-trashbar__text">{{ trashBannerText }}</p>
6561
+ <!-- Offered only when the SERVER has said this caller may purge. The
6562
+ backend refuses regardless of what we draw; this is so nobody is
6563
+ handed a button that always fails. -->
6564
+ <button
6565
+ v-if="trashCanEmpty"
6566
+ type="button"
6567
+ class="fe-btn fe-btn--danger fe-trashbar__action"
6568
+ :disabled="files.length === 0 || trashEmptying"
6569
+ data-testid="trash-empty"
6570
+ @click="showTrashConfirm = true"
6571
+ >
6572
+ {{ t('trash.empty_action') }}
6573
+ </button>
6574
+ </div>
6575
+ </template>
6576
+
6577
+ <!-- The three states with no listing behind them. `hostBodyState` keeps
6578
+ them in the order the old chain had — and, crucially, behind the
6579
+ skeleton, so a folder that is still loading does not flash its lock
6580
+ screen. -->
6581
+ <template #body>
6582
+ <!-- gorunum:v3-shell — Home. FIRST in the chain, and it short-circuits
6583
+ every state below it on purpose: those all describe a LISTING (a
6584
+ dead deep link, a failed fetch, a locked folder, an empty folder)
6585
+ and Home has no listing behind it — `files` is deliberately empty
6586
+ while it is open, which every one of them would read as "nothing
6587
+ here". Its own three blocks each carry their own empty state. -->
6588
+ <HomeView
6589
+ v-if="hostBodyState === 'home'"
6590
+ :storages="homeStorages"
6591
+ :recent="homeRecent"
6592
+ :starred="homeStarred"
6593
+ :loading="homeLoading"
6594
+ :locale="locale"
6595
+ :name-filter="driveFilters.name ?? '' /* surucu:d1-scope — Home's filter
6596
+ row is the name box alone, and this is what it narrows. The same
6597
+ `DriveFilters.name` the listing's own box writes, so the value is
6598
+ reset on navigation by the one watcher that already does that and
6599
+ there is no second piece of filter state to keep in step. */"
6600
+ :thumb-src="thumbs.src"
6601
+ @open-storage="openNavStorage"
6602
+ @open-node="openNode"
6603
+ @context-node="onContextTarget"
6604
+ />
4975
6605
  <!-- Dead deep link (404) or RBAC-hidden dir (403, shown identically):
4976
6606
  a dedicated state instead of a misleading "this folder is empty". -->
4977
- <div v-else-if="notFoundPath" class="fe-state">
6607
+ <div v-else-if="hostBodyState === 'notfound'" class="fe-state">
4978
6608
  <svg
4979
6609
  class="fe-state__art"
4980
6610
  viewBox="0 0 120 100"
@@ -5000,46 +6630,11 @@ function closeRecoveryKey() {
5000
6630
  </button>
5001
6631
  </div>
5002
6632
  </div>
5003
- <!-- Listing failed (network / 5xx) with nothing else to show: retryable
5004
- error state in the same visual language. -->
5005
- <div v-else-if="loadError && files.length === 0" class="fe-state">
5006
- <svg
5007
- class="fe-state__art"
5008
- viewBox="0 0 120 100"
5009
- width="110"
5010
- height="92"
5011
- fill="none"
5012
- stroke="currentColor"
5013
- stroke-width="2"
5014
- stroke-linecap="round"
5015
- stroke-linejoin="round"
5016
- aria-hidden="true"
5017
- >
5018
- <circle cx="60" cy="50" r="28" />
5019
- <path d="M60 36v18" />
5020
- <circle cx="60" cy="63" r="1.8" fill="currentColor" stroke="none" />
5021
- <path d="M24 88h72" stroke-dasharray="3 5" />
5022
- </svg>
5023
- <p class="fe-state__title">{{ t('error.title') }}</p>
5024
- <!-- wiring:c4 — friendly hint + collapsible technical detail; the raw
5025
- error message used to sit in the hint slot and read like UI copy. -->
5026
- <p class="fe-state__hint">{{ t('error.hint') }}</p>
5027
- <div class="fe-state__actions">
5028
- <button type="button" class="fe-btn fe-btn--primary" @click="retryLoad">
5029
- {{ t('error.retry') }}
5030
- </button>
5031
- </div>
5032
- <details class="fe-state__details">
5033
- <summary class="fe-state__details-summary">{{ t('error.details') }}</summary>
5034
- <pre class="fe-state__details-pre">{{ loadError }}</pre>
5035
- </details>
5036
- <!-- /wiring:c4 -->
5037
- </div>
5038
6633
  <!-- wiring:e2 — encrypted-folder lock screen: the listing is not
5039
6634
  rendered until the correct password is entered. The password is
5040
6635
  verified against the marker in the browser; it never reaches the
5041
6636
  server. -->
5042
- <div v-else-if="e2eLocked" class="fe-state fe-e2e-lock">
6637
+ <div v-else class="fe-state fe-e2e-lock">
5043
6638
  <svg
5044
6639
  class="fe-state__art"
5045
6640
  viewBox="0 0 120 100"
@@ -5065,6 +6660,8 @@ function closeRecoveryKey() {
5065
6660
  type="password"
5066
6661
  class="fe-input fe-e2e-lock__input"
5067
6662
  :placeholder="t('e2e.locked.pw_placeholder')"
6663
+ :aria-label="t('e2e.locked.pw_placeholder') /* the title and hint above
6664
+ say what this screen is; the field still needs its own name */"
5068
6665
  autocomplete="current-password"
5069
6666
  :disabled="e2eUnlockBusy"
5070
6667
  />
@@ -5086,8 +6683,14 @@ function closeRecoveryKey() {
5086
6683
  </button>
5087
6684
  </div>
5088
6685
  <!-- /wiring:e2 -->
6686
+ </template>
6687
+
6688
+ <!-- Loaded and empty: WHICH empty. The pane's own fallback ("this folder
6689
+ is empty") is the last branch here, and it is the one the split pane
6690
+ falls back to. -->
6691
+ <template #empty>
5089
6692
  <!-- Search with zero hits — its own message, not "folder is empty". -->
5090
- <div v-else-if="!loading && files.length === 0 && searchQuery" class="fe-state">
6693
+ <div v-if="searchQuery" class="fe-state">
5091
6694
  <svg
5092
6695
  class="fe-state__art"
5093
6696
  viewBox="0 0 120 100"
@@ -5111,7 +6714,7 @@ function closeRecoveryKey() {
5111
6714
  how it fills up; "This folder is empty" would be wrong twice over,
5112
6715
  because there is no folder and nothing to drop into it. -->
5113
6716
  <div
5114
- v-else-if="!loading && files.length === 0 && navView && navView !== 'trash'"
6717
+ v-else-if="navView && navView !== 'trash'"
5115
6718
  class="fe-state"
5116
6719
  :data-testid="`empty-${navView}`"
5117
6720
  >
@@ -5156,7 +6759,7 @@ function closeRecoveryKey() {
5156
6759
  </p>
5157
6760
  </div>
5158
6761
  <!-- Empty trash view. -->
5159
- <div v-else-if="!loading && files.length === 0 && trashMode" class="fe-state">
6762
+ <div v-else-if="trashMode" class="fe-state">
5160
6763
  <svg
5161
6764
  class="fe-state__art"
5162
6765
  viewBox="0 0 120 100"
@@ -5175,41 +6778,15 @@ function closeRecoveryKey() {
5175
6778
  <path d="M52 44v32M60 44v32M68 44v32" opacity="0.5" />
5176
6779
  </svg>
5177
6780
  <p class="fe-state__title">{{ t('empty.trash.title') }}</p>
5178
- </div>
5179
- <!-- surucu:d1 the folder HAS rows and the filters hid all of them.
5180
- "This folder is empty" would be false, and the way back is the chip
5181
- row just above, so the message names it and offers the button. -->
5182
- <div
5183
- v-else-if="!loading && filtersOn && displayFiles.length === 0 && files.length > 0"
5184
- class="fe-state"
5185
- data-testid="empty-filtered"
5186
- >
5187
- <svg
5188
- class="fe-state__art"
5189
- viewBox="0 0 120 100"
5190
- width="110"
5191
- height="92"
5192
- fill="none"
5193
- stroke="currentColor"
5194
- stroke-width="2"
5195
- stroke-linecap="round"
5196
- stroke-linejoin="round"
5197
- aria-hidden="true"
5198
- >
5199
- <path d="M26 28h68L68 58v24l-16 8V58z" />
5200
- </svg>
5201
- <p class="fe-state__title">{{ t('filter.empty.title') }}</p>
5202
- <p class="fe-state__hint">{{ t('filter.empty.hint') }}</p>
5203
- <div class="fe-state__actions">
5204
- <button type="button" class="fe-btn" @click="clearDriveFilters">
5205
- {{ t('filter.clear') }}
5206
- </button>
5207
- </div>
6781
+ <!-- tablo:t1 — the second line the reference has and we did not. The
6782
+ key did not exist in EITHER catalogue, so there was nothing to
6783
+ show even if something had asked for it. -->
6784
+ <p class="fe-state__hint">{{ t('empty.trash.hint') }}</p>
5208
6785
  </div>
5209
6786
  <!-- Loaded, zero files, no search: the real empty-folder state. The
5210
6787
  upload affordances follow write permission (RBAC viewers only get
5211
6788
  the title). -->
5212
- <div v-else-if="!loading && files.length === 0" class="fe-state">
6789
+ <div v-else class="fe-state">
5213
6790
  <svg
5214
6791
  class="fe-state__art"
5215
6792
  viewBox="0 0 120 100"
@@ -5236,127 +6813,120 @@ function closeRecoveryKey() {
5236
6813
  </button>
5237
6814
  </div>
5238
6815
  </div>
5239
- <ListView
5240
- v-else-if="viewMode === 'list'"
5241
- :files="displayFiles /* surucu:d1 */"
5242
- :selected="selection.selected.value"
5243
- :clipped="clippedPaths"
5244
- :show-parent-path="!!searchQuery"
5245
- :locale="locale"
5246
- :loading="loading"
5247
- :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
5248
- :starred-ids="starredIds"
5249
- :star-enabled="identitySurfaces"
5250
- :api-base="props.config.apiBase ?? ''"
5251
- :auth-headers="() => buildAuthHeaders()"
5252
- :auth-credentials="api.credentialsMode()"
5253
- @click-row="(n, m) => selection.click(n.path, m)"
5254
- @dbl-row="openNode"
5255
- @context-row="onContextTarget"
5256
- @item-drag-start="onItemDragStart"
5257
- @item-drop-into="onItemDropInto"
5258
- @star-change="onStarChange"
5259
- />
5260
- <GridView
5261
- v-else-if="viewMode === 'grid' /* wiring:d2 — v-else → v-else-if (3. mod eklendi) */"
5262
- :files="displayFiles /* surucu:d1 */"
5263
- :sections="driveShell /* surucu:d1 */"
5264
- :selected="selection.selected.value"
5265
- :clipped="clippedPaths"
5266
- :show-parent-path="!!searchQuery"
5267
- :locale="locale"
5268
- :loading="loading"
5269
- :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
5270
- :thumb-src="thumbs.src"
5271
- :starred-ids="starredIds"
5272
- :star-enabled="identitySurfaces"
5273
- :api-base="props.config.apiBase ?? ''"
5274
- :auth-headers="() => buildAuthHeaders()"
5275
- :auth-credentials="api.credentialsMode()"
5276
- @click-card="(n, m) => selection.click(n.path, m)"
5277
- @dbl-card="openNode"
5278
- @context-card="onContextTarget"
5279
- @item-drag-start="onItemDragStart"
5280
- @item-drop-into="onItemDropInto"
5281
- @star-change="onStarChange"
5282
- />
5283
- <!-- wiring:d2 — gallery view (same event contract as GridView) -->
5284
- <GalleryView
5285
- v-else
5286
- :files="displayFiles /* surucu:d1 */"
5287
- :selected="selection.selected.value"
5288
- :clipped="clippedPaths"
5289
- :show-parent-path="!!searchQuery"
5290
- :locale="locale"
5291
- :loading="loading"
5292
- :thumb-src="thumbs.src"
5293
- :starred-ids="starredIds"
5294
- :star-enabled="identitySurfaces"
5295
- :api-base="props.config.apiBase ?? ''"
5296
- :auth-headers="() => buildAuthHeaders()"
5297
- :auth-credentials="api.credentialsMode()"
5298
- @click-card="(n, m) => selection.click(n.path, m)"
5299
- @dbl-card="openNode"
5300
- @context-card="onContextTarget"
5301
- @item-drag-start="onItemDragStart"
5302
- @item-drop-into="onItemDropInto"
5303
- @star-change="onStarChange"
5304
- />
5305
- <!-- /wiring:d2 -->
5306
- </div>
5307
- </div><!-- /fe__primary ui-fix -->
5308
-
5309
- <!-- wiring:d1 — per-tab split: the secondary pane on the right (off in
5310
- narrow mode). :key is bound to the tab id — on a tab switch the pane
5311
- remounts cleanly with its own location. -->
5312
- <SecondaryPane
5313
- :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
6816
+ </template>
6817
+ </FilePane>
6818
+
6819
+ <!-- pane:p1 — the split pane. The SAME component, SELF-DRIVEN: it asks the
6820
+ backend for one folder through the same `api.index` + `lib/listing`
6821
+ helpers. `:key` is bound to the tab id, so a tab switch remounts it
6822
+ with its own location. -->
6823
+ <FilePane
5314
6824
  v-if="splitVisible && activeSplit"
5315
6825
  ref="splitPaneRef"
5316
6826
  :key="'split-' + tabsActiveId"
6827
+ pane-id="split"
6828
+ self-driven
6829
+ closable
5317
6830
  :api="api"
5318
- :initial-path="activeSplit.path"
5319
6831
  :locale="locale"
6832
+ :theme="themeMode"
6833
+ :focused="paneIsActive"
6834
+ :path="activeSplit.path"
5320
6835
  :qualify="qualify"
5321
6836
  :to-user="paneToUser"
5322
6837
  :clamp="paneClamp"
5323
- :root-label="multiStorageRoot ? '/' : adapter || t('breadcrumb.root')"
6838
+ :root-path="rootPathProp"
5324
6839
  :floor="rootFloor"
5325
6840
  :multi-root="multiStorageRoot"
5326
6841
  :virtual-rows="virtualStorageRows"
5327
- :active="paneIsActive"
5328
- :view-mode="paneViewMode /* ui-fix */"
5329
- :thumb-src="thumbs.src /* ui-fix */"
5330
- :trash-visible="config.trashVisible !== false /* ui-fix — trash row symmetry */"
5331
- :nav-offers-trash="navOffersTrash /* surucu:d1 — the same door is not opened twice */"
5332
- @navigate="onPaneNavigate"
6842
+ :trash-visible="config.trashVisible !== false"
6843
+ :nav-offers-trash="navOffersTrash"
6844
+ :view-mode="paneViewMode"
6845
+ :view-modes="allowedViewModes"
6846
+ :clipped="clippedPaths"
6847
+ :can-write="canWriteHere"
6848
+ :can-paste="!!clipboard.mode"
6849
+ :selected="splitSelection.selected.value"
6850
+ :filters="splitFilters"
6851
+ :folder-key="splitFolderKey"
6852
+ :thumb-src="thumbs.src"
6853
+ :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
6854
+ :starred-ids="starredIds"
6855
+ :star-enabled="identitySurfaces"
6856
+ :api-base="props.config.apiBase ?? ''"
6857
+ :auth-headers="() => buildAuthHeaders()"
6858
+ :auth-credentials="api.credentialsMode()"
5333
6859
  @activate="activePane = 'split'"
5334
6860
  @close="closeSplit"
5335
- @open-tab="(p: string) => tabsApi.openTab(p, { viewMode: viewMode, background: true })"
6861
+ @navigate="onPaneNavigate"
6862
+ @open="openNode"
6863
+ @open-trash="onPaneOpenTrash"
6864
+ @click-row="(n, m) => onPaneClickRow('split', n, m)"
6865
+ @context="(n: FileNode | null, ev: MouseEvent) => onPaneMenu('split', n, ev)"
6866
+ @clear-selection="splitSelection.clear()"
6867
+ @display-order="(nodes: FileNode[]) => (splitDisplayOrder = nodes)"
6868
+ @item-drag-start="(n: FileNode, ev: DragEvent) => onPaneItemDragStart('split', n, ev)"
6869
+ @item-drop-into="onItemDropInto"
5336
6870
  @transfer="onPaneTransfer"
5337
- @context="onPaneContext /* ui-fix — side-pane right-click menu */"
5338
- @open-trash="onPaneOpenTrash /* ui-fix the trash opens in the main pane */"
6871
+ @update:view-mode="setPaneViewMode"
6872
+ @update:filters="(v: DriveFilters) => (splitFilters = v)"
6873
+ @copy-path="onCopyPath"
6874
+ @crumb-drop="onCrumbDropInto"
6875
+ @new-folder="showNewFolder = true"
6876
+ @upload="triggerUpload"
6877
+ @paste="onToolbarAction('paste')"
6878
+ @select-all="splitSelection.selectAll()"
6879
+ @clear-filters="splitFilters = { ...EMPTY_FILTERS }"
6880
+ @star-change="onStarChange"
5339
6881
  />
5340
- <!-- /wiring:d1 -->
6882
+
6883
+ </div><!-- /fe__panes -->
6884
+ </div><!-- /fe__stack pane:p1 -->
6885
+
5341
6886
 
5342
6887
  <!-- koru:k1 — inspector (details) panel; v-if keeps the closed state
5343
6888
  free of any DOM. Narrow mode renders it as a full-size overlay. -->
5344
6889
  <InspectorPanel
5345
6890
  v-if="showInspector"
5346
6891
  :api="api"
5347
- :nodes="selection.nodes.value"
6892
+ :nodes="inspectorNodes /* pane:p1 — the FOCUSED pane's selection, through
6893
+ the same `activeTargets()` the selection bar reads. It was
6894
+ `selection.nodes` (the main pane's, always), which is why clicking
6895
+ in the split pane changed nothing here. */"
5348
6896
  :dir-label="inspectorDirLabel"
5349
- :dir-count="files.length"
5350
- :dir-perm="dirPerm"
6897
+ :dir-count="inspectorDirCount"
6898
+ :dir-perm="inspectorDirPerm"
6899
+ :held-in="inspectorHeldIn /* non-empty ⇒ this is the LAST selected thing,
6900
+ not what is ticked in front of you, and it lives here. */"
5351
6901
  :locale="locale"
5352
6902
  :narrow="isNarrow"
5353
6903
  :thumb-src="thumbs.src"
5354
- :tabs="driveShell /* surucu:d1 */"
6904
+ :api-base="props.config.apiBase ?? '' /* etiket:t1 — the details panel's
6905
+ Tags section mounts the same TagPicker the context menu opens, and
6906
+ that component talks to `/api/files/tags/*` itself. These three are
6907
+ the trio every self-fetching child in this package already takes
6908
+ (StarButton, TagPicker, GridView's star column): the base, the
6909
+ headers, and the credentials mode. ⚠ `authCredentials` is not
6910
+ decoration — a credentialed cross-origin request cannot be answered
6911
+ with `ACAO: *`, so an embed served from a different origin to the
6912
+ API breaks without it. */"
6913
+ :auth-headers="() => buildAuthHeaders()"
6914
+ :auth-credentials="api.credentialsMode()"
6915
+ @tags-changed="onNodeTagsChanged /* etiket:t1 — drop the panel's cached
6916
+ tag list when the details panel edits tags, exactly as the modal
6917
+ does. Harmless until InspectorPanel emits it. */"
6918
+ @open-tag="openTagView /* etiket:t1 — a tag chip in this panel is a door to
6919
+ that tag's view, the same door the navigation panel's Tags section
6920
+ opens. */"
5355
6921
  @close="closeInspector"
5356
6922
  @share-created="onInspectorShareCreated /* surucu:d1 */"
5357
6923
  @manage-permissions="onInspectorManage"
5358
6924
  @toast="flashToast"
5359
- @changed="() => load()"
6925
+ @changed="() => (paneIsActive ? void splitPaneRef?.reload() : void load()) /* pane:p1 —
6926
+ the panel now acts on the FOCUSED pane's selection, so the listing
6927
+ it refreshes afterwards has to be that pane's. Reloading the main
6928
+ one would leave a restored version, a deleted comment or a renamed
6929
+ file on screen unchanged in the half it happened in. */"
5360
6930
  />
5361
6931
  </div>
5362
6932
  <!-- /koru:k1 fe__main -->
@@ -5405,7 +6975,14 @@ function closeRecoveryKey() {
5405
6975
 
5406
6976
  <div v-if="dragOver" class="fe__dragover">
5407
6977
  <div class="fe__dragover-card">
5408
- <span class="fe-icon">⬆</span>
6978
+ <!-- ⚠ Was the `⬆` emoji: rendered by whatever emoji font the machine
6979
+ has, so the one mark on the drop overlay came out as a blue arrow
6980
+ on Windows, a grey one on Linux and nothing at all on a headless
6981
+ Chromium with no emoji font — on the single screen whose whole job
6982
+ is one glyph and one line. Same `upload` key the "+ New" menu and
6983
+ the toolbar draw, so the three now agree. -->
6984
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
6985
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('upload')"></span>
5409
6986
  <p>{{ t('dropzone.hint') }}</p>
5410
6987
  </div>
5411
6988
  </div>
@@ -5480,6 +7057,25 @@ function closeRecoveryKey() {
5480
7057
  @select="onContextAction"
5481
7058
  />
5482
7059
 
7060
+ <!-- belge:n1 — New document: the types this deployment can actually create
7061
+ AND open, then a name, then where it goes. -->
7062
+ <NewDocumentModal
7063
+ :open="showNewDocument"
7064
+ :locale="locale"
7065
+ :theme="themeMode"
7066
+ :api="api"
7067
+ :types="newDocTypes"
7068
+ :current-path="qualify(currentPath)"
7069
+ :storages="(props.config.storages ?? []).map((st) => st.name) /* ⚠ the dialog
7070
+ takes NAMES; `config.storages` is objects, and passing them
7071
+ straight through (as the wiring note had it) does not
7072
+ typecheck. */"
7073
+ :only-office-ready="!!effectiveOnlyOfficeBase"
7074
+ :drawio-ready="!!effectiveDrawioUrl"
7075
+ @close="showNewDocument = false"
7076
+ @created="onDocumentCreated"
7077
+ @error="emit('error', { message: $event.message, context: { op: 'newdoc' } })"
7078
+ />
5483
7079
  <NewFolderModal
5484
7080
  :open="showNewFolder"
5485
7081
  :locale="locale"
@@ -5488,6 +7084,26 @@ function closeRecoveryKey() {
5488
7084
  @submit="submitNewFolder"
5489
7085
  @encrypted="showNewFolder = false; showEncFolder = true /* wiring:e2 */"
5490
7086
  />
7087
+ <!-- tasi:m1 — "Şuraya taşı…" / "Şuraya kopyala…". ONE dialog for both, and
7088
+ the same one anything else that has to ask for a folder mounts (the
7089
+ new-document flow does): a second private folder browser is how two
7090
+ choosers start disagreeing about what a writable folder is. -->
7091
+ <DestinationPickerModal
7092
+ :open="showDestPicker"
7093
+ :api="api"
7094
+ :locale="locale"
7095
+ :mode="destPickerMode"
7096
+ :busy="destPickerBusy"
7097
+ :storages="(props.config.storages ?? []).map((st) => st.name) /* NAMES, not
7098
+ the objects — same shape NewDocumentModal takes */"
7099
+ :start-at="qualify(paneIsActive ? (splitPaneRef?.getPath() ?? '') : currentPath) /* open
7100
+ where the selection lives, not at the drive list */"
7101
+ :moving="destPickerMode === 'move'
7102
+ ? destPickerTargets.filter((n) => n.type === 'dir').map((n) => n.path)
7103
+ : [] /* only a MOVE can eat itself; a copy into your own subfolder is legal */"
7104
+ @close="showDestPicker = false"
7105
+ @pick="onDestinationPicked"
7106
+ />
5491
7107
  <!-- wiring:e2 — encrypted-folder creation modal -->
5492
7108
  <EncryptedFolderModal
5493
7109
  :open="showEncFolder"
@@ -5527,6 +7143,35 @@ function closeRecoveryKey() {
5527
7143
  @close="showRename = false"
5528
7144
  @submit="submitRename"
5529
7145
  />
7146
+ <!-- tablo:t1 — emptying the trash is irreversible and it is a BULK
7147
+ delete, so the question names what is about to go: how many things and
7148
+ how much space. "Empty the trash?" with no quantity is a question
7149
+ nobody can actually answer, and this is the last screen before the
7150
+ bytes are gone for good. -->
7151
+ <Modal
7152
+ :open="showTrashConfirm"
7153
+ :title="t('trash.empty_confirm_title')"
7154
+ size="sm"
7155
+ @close="showTrashConfirm = false"
7156
+ >
7157
+ <!-- ⚠ A singular form exists (`…_one`) and `t()` picks it from `count`:
7158
+ this is the last screen before an irreversible bulk delete, and
7159
+ "1 items" is not a sentence anybody should have to read there. -->
7160
+ <p>{{ t(trashConfirmKey, { count: files.length, size: formatSize(trashTotalBytes) }) }}</p>
7161
+ <template #actions>
7162
+ <button type="button" class="fe-btn" @click="showTrashConfirm = false">
7163
+ {{ t('modal.delete.cancel') }}
7164
+ </button>
7165
+ <button
7166
+ type="button"
7167
+ class="fe-btn fe-btn--danger"
7168
+ data-testid="trash-empty-confirm"
7169
+ @click="emptyTrash"
7170
+ >
7171
+ {{ t('trash.empty_action') }}
7172
+ </button>
7173
+ </template>
7174
+ </Modal>
5530
7175
  <DeleteConfirmModal
5531
7176
  :open="showDelete"
5532
7177
  :locale="locale"
@@ -5534,15 +7179,6 @@ function closeRecoveryKey() {
5534
7179
  @close="showDelete = false"
5535
7180
  @confirm="confirmDelete"
5536
7181
  />
5537
- <ShareModal
5538
- :open="showShare"
5539
- :locale="locale"
5540
- :share="activeShare"
5541
- :share-max-ttl-days="shareMaxTtlDays"
5542
- @close="closeShare"
5543
- @submit="submitShare"
5544
- @toast="flashToast"
5545
- />
5546
7182
  <PreviewModal
5547
7183
  :open="showPreview"
5548
7184
  :locale="locale"
@@ -5562,6 +7198,19 @@ function closeRecoveryKey() {
5562
7198
  :pdf-worker-url="props.config.pdfWorkerUrl || null"
5563
7199
  :pdf-save-url="props.config.pdfSaveUrl || null"
5564
7200
  :viewer-base-url="effectiveViewerBaseUrl"
7201
+ :index="previewPosition.index /* gorunum:v1 — the 3-of-9 counter */"
7202
+ :total="previewPosition.total"
7203
+ :nav-enabled="previewPosition.total > 1"
7204
+ :share-enabled="!e2eActive /* gorunum:v2 — the viewer's share icon opens the
7205
+ SAME dialog the menu opens. It shipped disabled because nothing was
7206
+ listening; an icon that does nothing is worse than no icon. Off inside
7207
+ an encrypted folder, where a link would serve ciphertext. */"
7208
+ @share="() => {
7209
+ const n = previewTarget;
7210
+ if (n) { permTarget = n; permInitialTab = undefined; showPerm = true; }
7211
+ }"
7212
+ :api-base="props.config.apiBase ?? ''"
7213
+ @nav="onPreviewNav"
5565
7214
  @close="showPreview = false"
5566
7215
  />
5567
7216
  <ConvertModal
@@ -5636,10 +7285,15 @@ function closeRecoveryKey() {
5636
7285
  <div class="fe-modal__body">
5637
7286
  <TagPicker
5638
7287
  :node-id="tagPickerNode.id"
7288
+ :locale="locale"
5639
7289
  :api-base="props.config.apiBase ?? ''"
5640
7290
  :auth-headers="() => buildAuthHeaders()"
5641
7291
  :auth-credentials="api.credentialsMode()"
5642
7292
  @change="onNodeTagsChanged"
7293
+ @open="openTagView /* etiket:t1 — the SAME door as in the details
7294
+ panel; `openTagView` closes this dialog on the way, because
7295
+ leaving a modal open over the view it just navigated to is
7296
+ a dialog nobody asked to keep. */"
5643
7297
  @error="(msg: string) => emit('error', { message: msg, context: { op: 'tags' } })"
5644
7298
  />
5645
7299
  </div>
@@ -5647,6 +7301,22 @@ function closeRecoveryKey() {
5647
7301
  </div>
5648
7302
  </transition>
5649
7303
 
7304
+ <!-- gorunum:v1-advsearch — the advanced search dialog. Its result lands in
7305
+ `files` through the same load() a toolbar search lands in, so there is
7306
+ exactly one results surface and one empty state. -->
7307
+ <AdvancedSearch
7308
+ :open="showAdvSearch"
7309
+ :locale="locale"
7310
+ :theme="themeMode"
7311
+ :initial-query="advSearchSeed"
7312
+ :folder-label="driveScopeLabel"
7313
+ :path-base="advPathBase"
7314
+ :content-search="advContentAvailable"
7315
+ :count="advSearchCount"
7316
+ @close="showAdvSearch = false"
7317
+ @submit="applyAdvancedSearch"
7318
+ />
7319
+
5650
7320
  <!-- cila:c wiring — command palette (Ctrl/Cmd+K) + shortcuts help (?) -->
5651
7321
  <CommandPalette
5652
7322
  :initial-query="paletteSeed /* surucu:d1 */"
@@ -5665,12 +7335,12 @@ function closeRecoveryKey() {
5665
7335
  @upload="triggerUpload"
5666
7336
  @toggle-view="setDisplayedViewMode(displayedViewMode === 'list' ? 'grid' : displayedViewMode === 'grid' ? 'gallery' : 'list') /* wiring:d2 + ui-fix — 3-mode cycle, to the active pane */"
5667
7337
  @open-trash="loadTrash"
5668
- @refresh="() => load()"
7338
+ @refresh="refreshAll /* gorunum:v2-topbar */"
5669
7339
  @go-up="goUp"
5670
7340
  @open-theme="showThemeGallery = true /* wiring:int */"
5671
7341
  @open-shortcut-settings="showShortcutSettings = true /* wiring:int */"
5672
7342
  @start-tour="startTour() /* wiring:int */"
5673
- :split-enabled="!isNarrow /* wiring:d1 */"
7343
+ :split-enabled="splitOffered /* wiring:d1 — see splitOffered */"
5674
7344
  @tab-new="newTabHere() /* wiring:d1 */"
5675
7345
  @split-toggle="toggleSplit() /* wiring:d1 */"
5676
7346
  />
@@ -5723,6 +7393,14 @@ function closeRecoveryKey() {
5723
7393
  </div>
5724
7394
  </transition>
5725
7395
 
7396
+ <!-- zaman:z3 — the embed's own time-zone setting (kept in this browser) -->
7397
+ <TimeZoneDialog
7398
+ :open="showTimeZone"
7399
+ :locale="locale"
7400
+ :theme="themeMode"
7401
+ @close="showTimeZone = false"
7402
+ />
7403
+
5726
7404
  <!-- wiring:c2 — shortcut settings modal + Space quick-look overlay -->
5727
7405
  <ShortcutSettings
5728
7406
  :open="showShortcutSettings"