@brftech/filex-core 0.42.1 → 0.43.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 (233) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5241 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1207 -343
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +47 -17
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -56
  76. package/src/components/SSHKeysPanel.vue +79 -26
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -27
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +121 -0
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +522 -119
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +539 -155
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2895 -241
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +58 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-CLNevfPE.js +0 -124
  216. package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
  217. package/dist/CsvViewer-K8XA3z7N.js +0 -141
  218. package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
  219. package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
  220. package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
  221. package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
  222. package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
  223. package/dist/MermaidViewer-DqucH_io.js +0 -137
  224. package/dist/MermaidViewer-DqucH_io.js.map +0 -1
  225. package/dist/PsdViewer-CwiVENmq.js +0 -122
  226. package/dist/PsdViewer-CwiVENmq.js.map +0 -1
  227. package/dist/TiffViewer-CpewSVmQ.js +0 -142
  228. package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
  229. package/dist/Viewer3D-CIrOn63I.js.map +0 -1
  230. package/dist/index-Dx0IbBcI.js +0 -26831
  231. package/dist/index-Dx0IbBcI.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
@@ -14,7 +14,7 @@
14
14
  * (PWA / OIDC) / CSRF (panel) / basic / none — `useFileApi` swallows
15
15
  * the difference.
16
16
  */
17
- import { computed, customRef, nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
17
+ import { computed, nextTick, onBeforeUnmount, onMounted, onScopeDispose, ref, watch, watchEffect } from 'vue';
18
18
  import type { ExplorerConfig, ThemeMode } from './types/ExplorerConfig';
19
19
  import type {
20
20
  FileNode,
@@ -32,8 +32,9 @@ import {
32
32
  } from './composables/useUploadChunked';
33
33
  import { useSelection } from './composables/useSelection';
34
34
  import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
35
- import { useLocale } from './composables/useLocale';
35
+ import { useLocale, localeTag } from './composables/useLocale';
36
36
  import { usePendingOps, type PendingOp } from './composables/usePendingOps';
37
+ import { usePluginActions } from './composables/usePluginActions'; /* App plugins — docs/APP-PLUGINS-API.md */
37
38
  import { useRealtime } from './composables/useRealtime';
38
39
  import { useThumbs } from './composables/useThumbs';
39
40
  import { preloadEditor } from './composables/useMonacoLoader';
@@ -46,20 +47,27 @@ import {
46
47
  activeSortDir,
47
48
  activeSortKey,
48
49
  applySort,
50
+ defaultSort,
49
51
  defaultSortDir,
50
- globalSort,
51
52
  setSortLocale,
52
53
  type ListingOrder,
53
54
  } from './lib/sortOrder'; /* surucu:d1-sort */
54
55
  import {
55
- attachViewPrefsStore,
56
+ ROOT_FOLDER_KEY,
57
+ defaultFolderView,
58
+ folderIsRemembered,
56
59
  folderKey as makeFolderKey,
57
- folderMemoryEnabled,
58
- folderPrefs,
60
+ onViewPrefsApplied,
59
61
  rememberFolder,
62
+ resolveFolderView,
63
+ setPersonFolderDefault,
60
64
  touchFolder,
61
65
  viewPrefsReady,
62
66
  } from './lib/viewPrefs'; /* tablo:t1 */
67
+ import { attachViewPrefsHttp } from './lib/viewPrefsHttp';
68
+ import { provideTableEnv } from './lib/tableEnv';
69
+ import { gateOnService, isOfficeExt, legacyConvertGate } from './lib/serviceGate';
70
+ import { opFailure } from './lib/errorWords';
63
71
  import { resolveUiProfile } from './lib/uiProfile';
64
72
  import RecentlyOpened from './components/RecentlyOpened.vue';
65
73
  import {
@@ -112,6 +120,7 @@ import { useOperations } from './composables/useOperations';
112
120
  /* /wiring:c3 */
113
121
  /* wiring:c4 */
114
122
  import OnboardingTour from './components/OnboardingTour.vue';
123
+ import { markTourSeen, offerTourOnce } from './lib/tour'; /* the tour is offered to a person once */
115
124
  /* /wiring:c4 */
116
125
  /* wiring:d1 — tabs + per-tab split */
117
126
  import TabBar from './components/TabBar.vue';
@@ -156,15 +165,25 @@ import {
156
165
  isVirtualViewPath,
157
166
  makeTagSegment,
158
167
  tagOfPath,
168
+ tagKindOfPath,
159
169
  showHiddenFiles,
160
170
  setShowHiddenFiles,
161
171
  injectTrashRow,
162
172
  hydrateTrashRow as hydrateTrashRowShared,
163
173
  } from './lib/listing';
174
+ import { nodeRowToFileNode as nodeRowToFileNodePure } from './lib/nodeRow'; /* Recent / Starred / tag / Home rows — one shape, with `perm` + `read_only` */
164
175
  import { iconFamilyFor, isStorageRow } from './lib/fileIcons'; /* pane:p1 — the storage-row predicate's one home */
165
176
  import { actionIconSvg } from './lib/actionIcons'; /* inceleme:r1 — the drop overlay's mark, off the emoji font */
177
+ import { convertAppOffered, isPluginActionKey, pluginActionKey, pluginMenuRows } from './lib/pluginMenu'; /* App plugins — the menu block, pure */
178
+ import { isPagePlacement, pluginPageUrl } from './lib/pluginPage'; /* App plugins — a `page` view opens in a new tab */
179
+ import { lockOf, lockWords, lockedRefusal } from './lib/appLock'; /* App plugins — an app's hold on a file */
180
+ import { linkWordsFor } from './lib/symlink'; /* issue #34 — a link the server will not follow */
181
+ import { labelOf as pluginLabelOf } from './lib/pluginLabel';
182
+ import type { PluginActionRow, PluginSurface, PluginViewRow } from './types/Plugins';
183
+ import type { NavApp } from './components/SideNav.vue';
166
184
  import { setNodeStarred } from './lib/star';
167
- import { fetchAllTags, fetchTaggedRows, invalidateTagCache } from './lib/tags';
185
+ import { emptyTrashAndFollow, TrashEmptyBusy, type TrashEmptyStatus } from './lib/trashEmpty';
186
+ import { fetchAllTags, fetchTaggedRows, onTagsChanged, type TagItem, type TagKind } from './lib/tags';
168
187
  import { resolveTransfer, type TransferIntent } from './lib/transfer';
169
188
  import { downloadArchive } from './lib/downloadSelection'; /* tasi:m1 */
170
189
  import { labelOfWire } from './lib/destinationTree'; /* tasi:m1 */
@@ -188,6 +207,8 @@ import DeleteConfirmModal from './modals/DeleteConfirmModal.vue';
188
207
  import Modal from './modals/Modal.vue'; /* tablo:t1 — the empty-trash confirmation */
189
208
  import PreviewModal from './modals/PreviewModal.vue';
190
209
  import ConvertModal from './modals/ConvertModal.vue';
210
+ import PluginViewModal from './components/plugin/PluginViewModal.vue'; /* App plugins */
211
+ import PluginConfirmModal from './components/plugin/PluginConfirmModal.vue';
191
212
  import PermissionsModal from './modals/PermissionsModal.vue';
192
213
  import DestinationPickerModal from './modals/DestinationPickerModal.vue'; /* tasi:m1 */
193
214
  import { resolveLocale } from './locales/resolve';
@@ -226,6 +247,29 @@ const emit = defineEmits<{
226
247
  * storage list simply ignores it.
227
248
  */
228
249
  (e: 'refresh'): void;
250
+ /**
251
+ * paylas:m1 — the navigation panel's "My shares" row was pressed.
252
+ *
253
+ * ⚠ Passed through, not acted on. "How to connect" and "API keys" are
254
+ * surfaces this component OWNS, so it opens them itself; the list of links a
255
+ * person minted is a page of the host application (the SPA's `my-shares`
256
+ * route), and the explorer has no business deciding what a host's URLs look
257
+ * like.
258
+ *
259
+ * ⚠ Which is why the row is behind `config.mySharesVisible` and that flag is
260
+ * OFF by default: an embedder who does not listen would otherwise be given
261
+ * the one row in the panel that leads nowhere. Listening and setting the
262
+ * flag are the same decision, made once — see the note on the row itself in
263
+ * SideNav.vue.
264
+ */
265
+ (e: 'open-my-shares'): void;
266
+ /**
267
+ * An "Apps" row was pressed and the host draws app home pages itself
268
+ * (`config.appHomePage`): the plugin and its `home` view. ⚠ Passed
269
+ * through, like `open-my-shares` — the page and its address are the
270
+ * host's.
271
+ */
272
+ (e: 'open-app-home', p: { plugin: string; view: string }): void;
229
273
  }>();
230
274
 
231
275
  // --------------------------------------------------------------------
@@ -248,7 +292,9 @@ const locale = computed(() => resolveLocale(props.config.locale));
248
292
  * either would leave the comparator on a stale alphabet exactly when they are
249
293
  * not mounted. */
250
294
  watch(locale, (l) => setSortLocale(l), { immediate: true });
251
- const { t, formatSize } = useLocale(locale); /* tablo:t1 — the empty-trash confirmation names the space */
295
+ const { t, formatSize, formatDate, dir } = useLocale(locale); /* tablo:t1 — the empty-trash confirmation names the space; `formatDate` reads an app lock's end on the user's clock */
296
+ // ⚠ RTL — `dir` goes on the root: the explorer's direction is its OWN locale's,
297
+ // never the host page's (lib/direction has the rule and why).
252
298
 
253
299
  // Live collaboration (WebSocket file-change events + presence), bundled into the
254
300
  // core so every consumer — the native panel AND the embedded webcomponent —
@@ -298,10 +344,21 @@ const pendingOps = usePendingOps(props.config, api, {
298
344
  onSettled: (op: PendingOp) => {
299
345
  const undo = opUndo.get(op.id);
300
346
  opUndo.delete(op.id);
347
+ /* "Empty the trash" says how it ended itself (emptyTrash below), and a
348
+ * row somebody cancelled is not announced as done. */
349
+ if (op.op_type === 'trash-empty' || op.status === 'cancelled') {
350
+ void load();
351
+ return;
352
+ }
301
353
  if (op.status === 'error') {
302
- flashToast(op.error_message || t('toast.failed'));
354
+ // Said, not printed: the server's error text is English and sometimes
355
+ // plumbing ("engine libreoffice is not installed on this host").
356
+ flashToast(opFailure(op, t).text);
303
357
  } else if (undo) {
304
358
  undoToast(`${undo.message} (${op.progress_total})`, undo.fn);
359
+ } else if (op.op_type === 'plugin') {
360
+ /* App plugins — the job's own last words, else "<label> finished". */
361
+ flashToast(op.message || t('plugin.done', { label: pluginOpLabel(op) }));
305
362
  } else {
306
363
  const verb =
307
364
  op.op_type === 'copy'
@@ -351,23 +408,27 @@ function retryLoad() {
351
408
  void load(loadErrorPath);
352
409
  }
353
410
 
411
+ /**
412
+ * The first-paint cache of the DEFAULT view mode (`lib/viewPrefs` writes it
413
+ * from the person's or the instance's default). ⚠ Read here, never written:
414
+ * see below.
415
+ */
354
416
  const VIEW_MODE_KEY = 'brf-file-explorer:view-mode';
355
417
  /**
356
- * tablo:t1 — true while a folder's REMEMBERED setup is being restored, as
357
- * opposed to a person choosing one.
418
+ * The view mode on screen.
358
419
  *
359
- * ⚠⚠ Without this the whole design inverts. `brf-file-explorer:view-mode` is
360
- * the GLOBAL default — what a folder nobody has configured opens as — and the
361
- * per-folder memory is layered over it. If restoring a folder's grid view also
362
- * wrote the global key, then merely WALKING INTO one folder you once set to
363
- * grid would make grid the default for the entire product, and the person
364
- * would have no way to tell which folder did it. Restoring is not choosing.
365
- * The same flag also keeps the watcher below from re-recording what it just
366
- * applied.
420
+ * ⚠⚠ Setting it writes NOTHING global, and that is the fix for the owner's
421
+ * report of 2026-09-21 — "Explore içindeki değişikliklerimiz o klasör özelinde
422
+ * olmalı; tüm klasörlerde görünüm değişikliği geçerli oluyor." It used to
423
+ * write `brf-file-explorer:view-mode` AND the account's global default on
424
+ * every change, under the rule "your last choice becomes the default for every
425
+ * folder you have never set up": switch folder A to grid and folder B, never
426
+ * touched, came up as grid. A change is now recorded against the folder it was
427
+ * made in (the recorder below) and nowhere else; the default is set on purpose
428
+ * in the person's settings or by the operator.
367
429
  */
368
- let restoringFolderView = false;
369
- const viewMode = customRef<ViewMode>((track, trigger) => {
370
- let value: ViewMode = (() => {
430
+ const viewMode = ref<ViewMode>(
431
+ (() => {
371
432
  try {
372
433
  const stored = localStorage.getItem(VIEW_MODE_KEY);
373
434
  if (stored === 'list' || stored === 'grid' || stored === 'gallery') return stored; /* wiring:d2 */
@@ -375,30 +436,20 @@ const viewMode = customRef<ViewMode>((track, trigger) => {
375
436
  /* private mode */
376
437
  }
377
438
  return props.config.viewMode ?? 'list';
378
- })();
379
- return {
380
- get() {
381
- track();
382
- return value;
383
- },
384
- set(next) {
385
- if (next === value) return;
386
- value = next;
387
- if (!restoringFolderView) {
388
- try {
389
- localStorage.setItem(VIEW_MODE_KEY, next);
390
- } catch {
391
- /* quota */
392
- }
393
- }
394
- trigger();
395
- },
396
- };
397
- });
439
+ })(),
440
+ );
398
441
  /* cila:a density — Toolbar owns the persisted preference (filex.density);
399
442
  mirrored here only so the root `.fe` can carry fe--density-compact. */
400
443
  const density = ref<'comfortable' | 'compact'>('comfortable');
401
444
  const searchQuery = ref('');
445
+ /** The search answer on screen was cut: more rows matched than came back
446
+ * (`truncated` on the response; lib/advSearch `advSearchTruncated`). Reset by
447
+ * every load(), so it can only ever describe the listing that is showing. */
448
+ const searchTruncated = ref(false);
449
+ /** The manager's search action asks the index for this many hits
450
+ * (handlers.Manager, managerSearchPage) — the page an older server's full
451
+ * answer is recognised by. */
452
+ const MANAGER_SEARCH_PAGE = 250;
402
453
  // trashMode — true while viewing the filex trash (soft-deleted nodes from the
403
454
  // backend trash endpoint), entered by opening the virtual `.trash` row and
404
455
  // exited by any normal navigation (load() resets it). Replaces a brittle
@@ -465,66 +516,82 @@ const folderMemoryOn = computed(() => props.config.rememberFolderView !== false)
465
516
  const currentFolderKey = computed(() => {
466
517
  if (!folderMemoryOn.value) return '';
467
518
  const path = String(currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
468
- if (!path) return '';
519
+ /* The listing of every storage is a place too: a person who sorts their
520
+ drives expects them to stay sorted, and `''` would mean "no folder", whose
521
+ changes land on the person's DEFAULT. */
522
+ if (!path) return ROOT_FOLDER_KEY;
469
523
  const [first, ...rest] = path.split('/');
470
524
  const st = (props.config.storages ?? []).find((s) => s.name === first);
471
525
  return makeFolderKey(st?.uid || first, rest.join('/'));
472
526
  });
473
527
 
474
- /** The GLOBAL default view mode — what a folder nobody has configured opens
475
- * as. Read from the same key the `viewMode` ref persists to, so there is one
476
- * answer rather than a second copy drifting beside it. */
477
- function globalViewMode(): ViewMode {
478
- try {
479
- const stored = localStorage.getItem(VIEW_MODE_KEY);
480
- if (stored === 'list' || stored === 'grid' || stored === 'gallery') return stored;
481
- } catch {
482
- /* private mode */
528
+ /**
529
+ * The DEFAULT view mode — what a folder nobody has configured opens as: the
530
+ * person's default, else the instance's (`lib/viewPrefs.defaultFolderView`),
531
+ * else this host's `config.viewMode`, else list. Until the account's document
532
+ * has landed, this browser's cached copy of that answer stands in for it.
533
+ */
534
+ function defaultViewMode(): ViewMode {
535
+ const d = defaultFolderView().v;
536
+ if (d) return d;
537
+ if (!viewPrefsReady()) {
538
+ try {
539
+ const stored = localStorage.getItem(VIEW_MODE_KEY);
540
+ if (stored === 'list' || stored === 'grid' || stored === 'gallery') return stored;
541
+ } catch {
542
+ /* private mode */
543
+ }
483
544
  }
484
545
  return props.config.viewMode ?? 'list';
485
546
  }
486
547
 
487
- /** What we last RESTORED, so the recorder below can tell a restore's echo from
488
- * a person's choice.
548
+ /**
549
+ * The stored document landed — or another browser's newer one did when this
550
+ * tab came back to the front, or the operator's default arrived with it.
551
+ * Re-apply the folder on screen: its own memory wins, and a folder with none
552
+ * follows whatever the default now is.
489
553
  *
490
- * ⚠⚠ A signature and not a boolean flag, and the difference is a bug I would
491
- * otherwise have shipped: Vue's watchers are asynchronous, so a `restoring =
492
- * true … restoring = false` fence around the assignment is already back down
493
- * by the time the recorder runs and every navigation would record itself as a
494
- * deliberate choice — which would mean walking through a folder configures it.
495
- * The `viewMode` setter's own guard CAN be a flag because a `customRef` setter
496
- * runs synchronously inside the fence; these two need different mechanisms
497
- * because they run at different times. */
498
- let appliedSig = '';
554
+ * ⚠ Unsubscribed with the component: the registry is module-level and two
555
+ * explorers can be mounted at once.
556
+ */
557
+ onScopeDispose(
558
+ onViewPrefsApplied(() => {
559
+ if (!viewPrefsReady()) return;
560
+ applyFolderView(currentFolderKey.value);
561
+ }),
562
+ );
499
563
 
500
- function viewSig(key: string): string {
501
- return `${key}|${viewMode.value}|${activeSortKey()}|${activeSortDir()}`;
502
- }
564
+ /**
565
+ * What the folder on screen was last put into — by the applier, or by the
566
+ * recorder after it wrote a change down. The recorder compares against it to
567
+ * tell a person's change from a restore's echo, and to know WHICH field
568
+ * changed.
569
+ *
570
+ * ⚠⚠ A record and not a `restoring = true … false` fence: Vue's watchers are
571
+ * asynchronous, so a fence around an assignment is already back down by the
572
+ * time the recorder runs, and every navigation would record itself as a
573
+ * deliberate choice — walking through a folder would configure it.
574
+ */
575
+ let applied: { key: string; v: ViewMode; k: string; d: string } | null = null;
503
576
 
504
- /** A navigation ended: put this folder back the way it was left. */
577
+ /** A navigation ended (or the defaults changed): put this folder back the way
578
+ * it was left — its own memory, else the default, field by field. */
505
579
  function applyFolderView(key: string) {
506
- if (!key) return;
580
+ if (!key && folderMemoryOn.value) return;
507
581
  touchFolder(key); // LRU clock — only bumps folders already remembered
508
- const p = folderPrefs(key);
509
- restoringFolderView = true;
510
- try {
511
- /* ⚠ The `else` halves matter as much as the `if`s. Without them a folder
512
- * with no memory of its own would inherit whatever the PREVIOUS folder was
513
- * restored to — walk from a remembered gallery into a plain folder and it
514
- * comes up as a gallery, which reads as the memory leaking rather than as
515
- * a default holding. A folder with no memory follows the global default,
516
- * and that has to be asserted, not assumed. */
517
- const wantView = p?.v ?? globalViewMode();
518
- if (wantView !== viewMode.value) viewMode.value = wantView;
519
- if (p?.k) applySort(p.k, p.d ?? defaultSortDir(p.k));
520
- else {
521
- const g = globalSort();
522
- applySort(g.key, g.dir);
523
- }
524
- } finally {
525
- restoringFolderView = false;
582
+ const p = key ? resolveFolderView(key) : {};
583
+ /* ⚠ The `else` halves matter as much as the `if`s. Without them a folder
584
+ * with no memory of its own would inherit whatever the PREVIOUS folder was
585
+ * put into — walk from a remembered gallery into a plain folder and it
586
+ * comes up as a gallery, which is the leak wearing a different hat. */
587
+ const wantView = p.v ?? defaultViewMode();
588
+ if (wantView !== viewMode.value) viewMode.value = wantView;
589
+ if (p.k) applySort(p.k, p.d ?? defaultSortDir(p.k));
590
+ else {
591
+ const g = defaultSort();
592
+ applySort(g.key, g.dir);
526
593
  }
527
- appliedSig = viewSig(key);
594
+ applied = { key, v: viewMode.value, k: activeSortKey(), d: activeSortDir() };
528
595
  }
529
596
 
530
597
  /**
@@ -537,45 +604,13 @@ function applyFolderView(key: string) {
537
604
  * land first in practice, and nothing is applied to a folder until they do.
538
605
  */
539
606
  onMounted(() => {
540
- const base = props.config.apiBase ?? '';
541
- const url = `${base}/api/files/manager/view-prefs`;
542
- attachViewPrefsStore({
543
- async load() {
544
- const res = await fetch(url, {
545
- headers: await buildAuthHeaders(),
546
- credentials: api.credentialsMode(),
547
- });
548
- /* ⚠ A 401 is not an error here, it is an ANSWER: an app token or a
549
- * public share link has no person to remember anything for. Returning
550
- * null degrades to "remember nothing, write nothing", which is what the
551
- * module does with it — no retry loop, no console noise. */
552
- if (!res.ok) return null;
553
- const body = (await res.json()) as { prefs?: unknown };
554
- return body?.prefs ?? null;
555
- },
556
- save(doc) {
557
- void (async () => {
558
- try {
559
- const payload = JSON.stringify({ prefs: doc });
560
- await fetch(url, {
561
- method: 'PUT',
562
- headers: { ...(await buildAuthHeaders()), 'Content-Type': 'application/json' },
563
- credentials: api.credentialsMode(),
564
- body: payload,
565
- /* ⚠ `keepalive` is what lets the save fired on `pagehide` outlive
566
- * the page — but browsers cap a keepalive body at 64 KB and reject
567
- * the request outright above it. The document is capped far below
568
- * that (300 folders ≈ 33 KB), so this only ever guards the
569
- * pathological case; sending it without keepalive is strictly
570
- * better than having it rejected. */
571
- keepalive: payload.length < 60000,
572
- });
573
- } catch {
574
- /* Fire and forget. A view preference is never worth a toast, and the
575
- next save carries the whole document again. */
576
- }
577
- })();
578
- },
607
+ /* The one transport (`lib/viewPrefsHttp`), shared with the admin app. A
608
+ * no-op when the host already attached it — which the admin app does on
609
+ * sign-in, because its tables keep their columns in the same document. */
610
+ attachViewPrefsHttp({
611
+ apiBase: props.config.apiBase ?? '',
612
+ headers: buildAuthHeaders,
613
+ credentials: api.credentialsMode(),
579
614
  });
580
615
  });
581
616
 
@@ -599,35 +634,58 @@ watch(
599
634
  { immediate: true, flush: 'post' },
600
635
  );
601
636
 
602
- /** A person changed a view. Record it against the folder — and note that the
603
- * GLOBAL default was already written by the control they used (`setSort`
604
- * persists; the `viewMode` setter persists), which is what makes "Apply to
605
- * all folders" a pure forget rather than a second write. */
637
+ /**
638
+ * A person changed a view. Record it against THE FOLDER — and only the field
639
+ * that changed: switching to grid records the view mode and leaves the
640
+ * folder's sort following the default, so a default sort chosen later still
641
+ * reaches it.
642
+ *
643
+ * ⚠⚠ Nothing global is written here or anywhere else on a click. This is the
644
+ * fix for "tüm klasörlerde görünüm değişikliği geçerli oluyor".
645
+ *
646
+ * ⚠ A host that switched the per-folder memory OFF (`rememberFolderView:
647
+ * false`) has no folder to record against (`currentFolderKey` is ''), and asked
648
+ * for one arrangement everywhere — so for it, and only for it, a change is the
649
+ * person's default.
650
+ */
606
651
  watch(
607
652
  () => [currentFolderKey.value, viewMode.value, activeSortKey(), activeSortDir()] as const,
608
653
  ([key, v, k, d]) => {
609
- /* ⚠ Nothing is recorded before the document has landed. Until then the
610
- * state on screen is this session's defaults, not the person's choices,
611
- * and writing it back would overwrite everything they had arranged with
612
- * whatever the app happened to boot into. */
613
- if (!key || !viewPrefsReady()) return;
614
- const sig = viewSig(key);
615
- if (sig === appliedSig) return; // the restore, echoing back
616
- appliedSig = sig;
617
- rememberFolder(key, { v, k, d });
654
+ /* ⚠ Nothing is recorded before the document has landed: until then the
655
+ * state on screen is this session's defaults, not the person's choices. */
656
+ if (!viewPrefsReady()) return;
657
+ if (!applied || applied.key !== key) return; // a navigation — the applier's turn
658
+ const patch: { v?: ViewMode; k?: typeof k; d?: typeof d } = {};
659
+ if (v !== applied.v) patch.v = v;
660
+ if (k !== applied.k || d !== applied.d) {
661
+ patch.k = k;
662
+ patch.d = d;
663
+ }
664
+ applied = { key, v, k, d };
665
+ if (!Object.keys(patch).length) return;
666
+ if (key) rememberFolder(key, patch);
667
+ else if (!folderMemoryOn.value) setPersonFolderDefault(patch);
618
668
  },
619
- /* ⚠⚠ `post`, and registered AFTER the applier, for a reason that is not
620
- * stylistic. A navigation changes `currentFolderKey`, which is a dependency
621
- * of BOTH watchers. Left pre-flush this one would run first, while the view
622
- * state is still the folder you just LEFT, and write that folder's view mode
623
- * and sort against the folder you just arrived in — every walk through the
624
- * tree quietly configuring the next folder with the last one's setup. Post
625
- * puts the applier first; by the time this runs, `appliedSig` matches and it
626
- * correctly does nothing. */
669
+ /* ⚠⚠ `post`, and registered AFTER the applier: a navigation changes
670
+ * `currentFolderKey`, which both watchers depend on. Pre-flush this would
671
+ * run first, while the view state is still the folder you LEFT, and record
672
+ * that folder's setup against the one you arrived in. */
627
673
  { flush: 'post' },
628
674
  );
675
+
676
+ /* "Forget this folder's view" (the column menu): put the folder on screen
677
+ * back onto the default straight away, rather than at the next navigation. */
678
+ watch(
679
+ () => folderIsRemembered(currentFolderKey.value),
680
+ (now, was) => {
681
+ if (was && !now && viewPrefsReady()) applyFolderView(currentFolderKey.value);
682
+ },
683
+ );
629
684
  /** The tag being browsed while navView === 'tag' ('' otherwise). */
630
685
  const navTag = ref<string>('');
686
+ /** …and which KIND of it (etiket:k2, v0.43): 'personal', 'team', or '' for
687
+ * both — what a `#.tag~x` link from before kinds existed still opens. */
688
+ const navTagKind = ref<TagKind | ''>('');
631
689
  /** Sentinel parked in `dirname` so the breadcrumb can label the view. The tag
632
690
  * view's sentinel is built per tag (`makeTagSegment`) — see lib/listing. */
633
691
  const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> = {
@@ -644,7 +702,9 @@ const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> =
644
702
  * for a folder called `.starred` and landing on "not found". (That was already
645
703
  * true of the four shipped views; the tag view would have inherited it.)
646
704
  */
647
- function virtualViewOf(path: string): { kind: Exclude<NavView, ''>; tag: string } | null {
705
+ function virtualViewOf(
706
+ path: string,
707
+ ): { kind: Exclude<NavView, ''>; tag: string; tagKind: TagKind | '' } | null {
648
708
  /* ⚠ The "is this a sentinel at all?" half is `lib/listing`'s
649
709
  `isVirtualViewPath`, not a second reading of the map here: FilePane has to
650
710
  answer the same question before it qualifies a path (a qualified sentinel
@@ -654,9 +714,9 @@ function virtualViewOf(path: string): { kind: Exclude<NavView, ''>; tag: string
654
714
  if (!isVirtualViewPath(path)) return null;
655
715
  const clean = String(path ?? '').replace(/^\/+|\/+$/g, '');
656
716
  const tag = tagOfPath(clean);
657
- if (tag) return { kind: 'tag', tag };
717
+ if (tag) return { kind: 'tag', tag, tagKind: tagKindOfPath(clean) };
658
718
  const kind = clean.slice(1) as Exclude<NavView, '' | 'tag'>;
659
- return { kind, tag: '' };
719
+ return { kind, tag: '', tagKind: '' };
660
720
  }
661
721
 
662
722
  // When the caller can see exactly ONE storage, the multi-storage root is a
@@ -810,6 +870,62 @@ watch(
810
870
  const clipboard = ref<ClipboardState>({ mode: null, items: [], sourcePath: null });
811
871
 
812
872
  const capabilitiesData = ref<Capabilities | null>(null);
873
+
874
+ /**
875
+ * `GET /api/capabilities`, asked once — RETRIED, and AWAITABLE.
876
+ *
877
+ * ⚠⚠ This used to be a fire-and-forget `.then(…).catch(() => {})` in
878
+ * `onMounted`, and that one swallowed catch was a silent, page-wide feature
879
+ * kill. Everything gated on capabilities reads a ref that a failed or slow
880
+ * call simply leaves null: app plugins (`pluginsEnabled` below), OnlyOffice,
881
+ * draw.io, the convert service, new-document types. Nothing retried, so ONE
882
+ * unlucky request — a reload during a backend restart, a proxy hiccup — turned
883
+ * every plugin deep link on that page load into a no-op, for the whole life of
884
+ * the page, with nothing in the console to say why.
885
+ *
886
+ * ⚠⚠ And it was a RACE as well as a failure mode. A notification's deep link
887
+ * arrives from the host (Explore.vue → `openAppTarget`) as soon as the row is
888
+ * revealed, which can be before this answer lands; `openAppTarget` then read
889
+ * `pluginsEnabled` as false and returned. Awaiting THIS PROMISE is the fix —
890
+ * not a delay before trying, which would only move the coin toss.
891
+ *
892
+ * ⚠ `settled`, not `loaded`: a caller waits for the question to be ANSWERED,
893
+ * including "the server would not say". Resolving only on success would hang
894
+ * every awaiting caller on an instance that has no such endpoint.
895
+ */
896
+ const CAPABILITIES_TRIES = 3;
897
+ let capabilitiesSettled: Promise<void> | null = null;
898
+
899
+ function loadCapabilities(): Promise<void> {
900
+ if (capabilitiesSettled) return capabilitiesSettled;
901
+ if (!api.endpoints.capabilities) {
902
+ capabilitiesSettled = Promise.resolve();
903
+ return capabilitiesSettled;
904
+ }
905
+ capabilitiesSettled = (async () => {
906
+ for (let attempt = 1; attempt <= CAPABILITIES_TRIES; attempt++) {
907
+ try {
908
+ capabilitiesData.value = await api.capabilities();
909
+ return;
910
+ } catch (e) {
911
+ if (attempt === CAPABILITIES_TRIES) {
912
+ // ⚠ SAID, not swallowed. The host logs this (Explore.vue →
913
+ // onExplorerError) so "my apps disappeared" has an answer in the
914
+ // console instead of being indistinguishable from "this instance
915
+ // has no apps".
916
+ emit('error', {
917
+ message: `capabilities: ${(e as Error)?.message ?? String(e)}`,
918
+ context: { what: 'capabilities', attempts: attempt },
919
+ });
920
+ return;
921
+ }
922
+ await new Promise((r) => setTimeout(r, 200 * attempt));
923
+ }
924
+ }
925
+ })();
926
+ return capabilitiesSettled;
927
+ }
928
+
813
929
  /* zaman:z3 — the two clock tiers only an explorer instance can know: the zone
814
930
  * its host configured, and the account behind its credential when that
815
931
  * credential is a person's. Ranked in lib/timezone, never here. */
@@ -823,6 +939,385 @@ useExplorerTimeZone({
823
939
  // ceiling). Both share dialogs derive their expiry choices from it.
824
940
  const shareMaxTtlDays = computed(() => capabilitiesData.value?.share_max_ttl_days ?? 0);
825
941
 
942
+ /* === App plugins (docs/APP-PLUGINS-API.md) ==============================
943
+ * Rows a WebAssembly plugin adds to the file menu, run as ops jobs. The
944
+ * feature follows `capabilities.app_plugins.enabled` unless the host decides
945
+ * (`config.plugins`); while it is off the explorer makes no plugin request.
946
+ * The actions list is fetched once and shared across instances
947
+ * (usePluginActions); the menu block is pure (lib/pluginMenu). */
948
+ const pluginsEnabled = computed<boolean>(() => {
949
+ if (typeof props.config.plugins === 'boolean') return props.config.plugins;
950
+ return capabilitiesData.value?.app_plugins?.enabled === true;
951
+ });
952
+ const pluginActions = usePluginActions(api, () => pluginsEnabled.value);
953
+
954
+ /** The open `modal` view: which plugin/view, its first surface, the row it was opened on. */
955
+ const pluginView = ref<{
956
+ plugin: string;
957
+ view: string;
958
+ surface: PluginSurface;
959
+ path?: string;
960
+ /** A `home` view is drawn full-size whatever the surface says. */
961
+ size?: 'xl';
962
+ } | null>(null);
963
+
964
+ /** The `inspector` views, for the details panel; `[]` while the feature is off. */
965
+ const pluginInspectorViews = computed<PluginViewRow[]>(() =>
966
+ pluginsEnabled.value ? pluginActions.views.value.filter((v) => v.placement === 'inspector') : [],
967
+ );
968
+
969
+ /** The `home` views, as the navigation panel's "Apps" rows. */
970
+ const pluginHomeApps = computed<NavApp[]>(() =>
971
+ pluginsEnabled.value
972
+ ? pluginActions.views.value
973
+ .filter((v) => v.placement === 'home')
974
+ .map((v) => ({ key: `${v.plugin}/${v.id}`, label: pluginLabelOf(v.label, locale.value) || v.id, icon: v.icon }))
975
+ : [],
976
+ );
977
+
978
+ /**
979
+ * An "Apps" row: the host's own page for it when it has one (same tab, its
980
+ * own address — `config.appHomePage`), else `GET …/views/{p}/{v}` with no
981
+ * path, drawn full-size in a dialog.
982
+ */
983
+ async function openPluginHome(key: string) {
984
+ const view = pluginActions.views.value.find((v) => `${v.plugin}/${v.id}` === key);
985
+ if (!view) return;
986
+ closeNavDrawer();
987
+ if (props.config.appHomePage === true) {
988
+ emit('open-app-home', { plugin: view.plugin, view: view.id });
989
+ return;
990
+ }
991
+ try {
992
+ const res = await api.pluginView(view.plugin, view.id);
993
+ if (!res?.surface) {
994
+ flashToast(t('plugin.view.error'));
995
+ return;
996
+ }
997
+ pluginView.value = { plugin: view.plugin, view: view.id, surface: res.surface, size: 'xl' };
998
+ } catch (e) {
999
+ flashToast((e as Error)?.message || t('plugin.view.error'));
1000
+ }
1001
+ }
1002
+ /** An action whose manifest asks for confirmation, waiting for the answer. */
1003
+ const pluginConfirm = ref<{ action: PluginActionRow; targets: FileNode[] } | null>(null);
1004
+
1005
+ /** Menu rows for a selection — hidden in the trash, inside an encrypted
1006
+ * folder and (through `selectionActionList`'s early return) on storage rows.
1007
+ * On a read-only storage the actions that write their result are left out
1008
+ * (`pluginActionWrites`): the folder's own flag for rows listed in it, each
1009
+ * row's storage for Recent / Starred / a tag / Home, where no folder is. */
1010
+ function pluginActionRows(sel: FileNode[]): ContextAction[] {
1011
+ if (!pluginsEnabled.value) return [];
1012
+ return pluginMenuRows(pluginActions.actions.value, sel, {
1013
+ locale: locale.value,
1014
+ trash: trashActive.value,
1015
+ e2e: e2eActive.value,
1016
+ readOnly: dirReadOnly.value || selReadOnly(sel),
1017
+ // The level each row answers with — the same one the built-in verbs
1018
+ // read — so an app's row a viewer could only be refused is not offered.
1019
+ permOf: (n) => rowPerm(n as FileNode),
1020
+ hasIcon: (name) => actionIconSvg(name) !== '',
1021
+ // Greyed rows with the reason are an ADMINISTRATOR's (the server sends
1022
+ // `gated` to them only; asked here too, so no other caller draws one).
1023
+ needWords: callerAdmin.value
1024
+ ? (need) => (need.kind === 'engine' ? t('plugin.needs_engine', { name: need.name }) : t('plugin.needs_other', { name: need.name }))
1025
+ : undefined,
1026
+ });
1027
+ }
1028
+
1029
+ /** What a queued plugin job is called in a toast: its label, or the action id. */
1030
+ function pluginOpLabel(op: PendingOp): string {
1031
+ return op.label || op.action || t('opc.kind.plugin');
1032
+ }
1033
+
1034
+ /**
1035
+ * Where an app job's result lands — the tray's little "new file" / "new
1036
+ * version" chip.
1037
+ *
1038
+ * ⚠ Read from the CACHED ACTIONS LIST, because the ops row does not carry it:
1039
+ * the queue knows the plugin and the action, and the action's manifest output
1040
+ * is what the list already holds. Two consequences worth knowing rather than
1041
+ * discovering: a hidden action (never listed) and a surface that overrode the
1042
+ * output for that one job (`job.output`, which the browser never sees) both
1043
+ * answer `undefined`, and the chip is simply not drawn. A wrong chip would be
1044
+ * worse than none — it is the difference between "your file was replaced" and
1045
+ * "a copy appeared".
1046
+ */
1047
+ function pluginOutputModeOf(op: PendingOp): string | undefined {
1048
+ if (!op.plugin || !op.action) return undefined;
1049
+ return pluginActions.actions.value.find((a) => a.plugin === op.plugin && a.id === op.action)?.output_mode;
1050
+ }
1051
+
1052
+ /** The menu row was chosen: confirm if the manifest asks, then run. */
1053
+ async function onPluginAction(key: string, targets: FileNode[]) {
1054
+ const action = pluginActions.byKey(key);
1055
+ if (!action || targets.length === 0) return;
1056
+ const confirm = pluginLabelOf(action.confirm, locale.value);
1057
+ if (confirm) {
1058
+ pluginConfirm.value = { action, targets };
1059
+ return;
1060
+ }
1061
+ await runPluginAction(action, targets);
1062
+ }
1063
+
1064
+ async function onPluginConfirmed() {
1065
+ const pending = pluginConfirm.value;
1066
+ pluginConfirm.value = null;
1067
+ if (pending) await runPluginAction(pending.action, pending.targets);
1068
+ }
1069
+
1070
+ /**
1071
+ * A `page` action: open the view as a whole page in a new tab and DO NOT run
1072
+ * anything here. The tab asks for the surface itself (`GET …/views/…?path=`)
1073
+ * and the job is born from its own submit — the same `{surface}` conversation
1074
+ * a modal has, in a frame that has room for a document.
1075
+ *
1076
+ * ⚠ Returns false when the host has not said where its pages live
1077
+ * (`config.pluginPageBase`) or when the browser refused the tab; the caller
1078
+ * then falls back to the dialog. A `page` action that silently does nothing
1079
+ * because a pop-up blocker spoke is indistinguishable from a broken app.
1080
+ */
1081
+ function openPluginPage(action: PluginActionRow, targets: FileNode[]): boolean {
1082
+ const view = action.view;
1083
+ const base = props.config.pluginPageBase;
1084
+ if (!view || base === undefined) return false;
1085
+ const target = { plugin: action.plugin, view, path: targets[0]?.path };
1086
+ const url = pluginPageUrl(base, target);
1087
+ if (props.config.openPluginPage?.({ ...target, url }) === true) return true;
1088
+ // ⚠⚠ `noopener` is NOT in the feature string, and that is not an oversight.
1089
+ // Per the HTML spec a `window.open` that sets `noopener` returns **null**
1090
+ // even when the tab opened perfectly — so a caller that reads the return as
1091
+ // "was it blocked?" gets a false every single time. Measured: the wizard
1092
+ // opened in its own tab AND a dialog appeared behind it, because this
1093
+ // function answered false and the caller fell back to `…/run`. The opener
1094
+ // is severed on the handle instead, which keeps the guarantee and leaves
1095
+ // `null` meaning the one thing worth knowing: a pop-up blocker spoke.
1096
+ const win = window.open(url, '_blank');
1097
+ if (!win) {
1098
+ flashToast(t('plugin.page_view.blocked'));
1099
+ return false;
1100
+ }
1101
+ try {
1102
+ win.opener = null;
1103
+ } catch {
1104
+ /* a browser that will not let us sever it still opened the page */
1105
+ }
1106
+ return true;
1107
+ }
1108
+
1109
+ /**
1110
+ * POST run. `{op}` → the ops tray (same path a copy takes); `{surface}` → the
1111
+ * view dialog, whose submit will bring the `{op}` later.
1112
+ */
1113
+ /**
1114
+ * A mutation was refused. ⚠⚠ `423` is an APP LOCK, not a permission problem:
1115
+ * a named app is holding the file until a named date, and the person is
1116
+ * otherwise perfectly allowed to do what they just tried. Dropping it into
1117
+ * the generic path told them "Error (423)" and, once a caller mapped it onto
1118
+ * the permission text, "you are not allowed to do this" — a sentence they
1119
+ * can do nothing with. Every rename / move / delete catch goes through here
1120
+ * so there is exactly one place that decides.
1121
+ */
1122
+ function reportMutationError(err: unknown, context: Record<string, unknown>): void {
1123
+ const held = lockedRefusal(err);
1124
+ if (held) flashToast(lockWords(held, { t, formatDate, locale: locale.value }));
1125
+ emit('error', { message: (err as Error)?.message ?? String(err), context });
1126
+ }
1127
+
1128
+ async function runPluginAction(action: PluginActionRow, targets: FileNode[]) {
1129
+ const label = pluginLabelOf(action.label, locale.value);
1130
+ if (isPagePlacement(action.view_placement) && openPluginPage(action, targets)) return;
1131
+ try {
1132
+ const res = await pluginActions.run(action, targets);
1133
+ if (res.surface) {
1134
+ pluginView.value = {
1135
+ plugin: action.plugin,
1136
+ view: action.view || action.id,
1137
+ surface: res.surface,
1138
+ path: targets[0]?.path,
1139
+ };
1140
+ return;
1141
+ }
1142
+ if (res.op) onPluginOpQueued(res.op, label);
1143
+ } catch (e) {
1144
+ const err = e as Error & { status?: number; detail?: string };
1145
+ const detail = String(err?.detail ?? '');
1146
+ const held = lockedRefusal(err);
1147
+ if (held) flashToast(lockWords(held, { t, formatDate, locale: locale.value }));
1148
+ else if (err?.status === 422 && detail.includes('not_applicable')) flashToast(t('plugin.not_applicable'));
1149
+ else if (err?.status === 409 && detail.includes('read_only')) flashToast(t('plugin.read_only'));
1150
+ else flashToast(err?.message || t('plugin.failed', { label }));
1151
+ }
1152
+ }
1153
+
1154
+ /** A job was enqueued (by `run` or by a view's submit): track it, say so. */
1155
+ function onPluginOpQueued(op: Record<string, unknown>, label?: string) {
1156
+ pendingOps.register(op);
1157
+ const name = label || (typeof op.label === 'string' ? op.label : '') || t('opc.kind.plugin');
1158
+ flashToast(t('plugin.queued', { label: name }));
1159
+ }
1160
+
1161
+ /**
1162
+ * A notification's deep link (`target.open`): show the file and open what the
1163
+ * app asked for on it — its action, or its view.
1164
+ *
1165
+ * ⚠⚠ Why this is exposed rather than a config prop. A bell click does not
1166
+ * remount anything: the person is already standing in the explorer, the hash
1167
+ * changes, the listing reloads. An `initialPluginAction` prop would fire once,
1168
+ * at mount, and every click after the first would land on the file and stop
1169
+ * there — which is the bug this whole field exists to fix ("please sign" that
1170
+ * drops you in a folder instead of in the signing screen).
1171
+ *
1172
+ * ⚠ The row may not be in the listing yet (a fresh tab, a folder still
1173
+ * loading), so a path that is not on screen is carried as a bare node. The
1174
+ * server re-checks `applies` and the ACL on every run, so the worst case is
1175
+ * an honest 422 rather than a wrong screen.
1176
+ *
1177
+ * ⚠⚠ It AWAITS the capability answer before reading `pluginsEnabled`. That
1178
+ * flag comes from `GET /api/capabilities`, which the explorer asks for in its
1179
+ * own `onMounted` — so whether this function found it filled was a matter of
1180
+ * which of two unrelated requests came back first. Measured as "roughly one
1181
+ * opening in eight the signing screen does not come up"; it was never the
1182
+ * plugin LIST (that was already awaited two lines down), it was the flag
1183
+ * gating it. Awaiting the promise removes the timing question instead of
1184
+ * betting on it.
1185
+ *
1186
+ * ⚠ Every refusal below now SAYS something. Four `return false`s in this path
1187
+ * were silent, and a deep link that silently does nothing is the hardest
1188
+ * possible bug to report: the person clicked a notification, the folder
1189
+ * opened, and the thing they were asked to do never appeared.
1190
+ */
1191
+ async function openAppTarget(p: {
1192
+ plugin: string;
1193
+ action?: string;
1194
+ view?: string;
1195
+ path: string;
1196
+ }): Promise<boolean> {
1197
+ if (!p.plugin || !p.path) return false;
1198
+ await loadCapabilities();
1199
+ if (!pluginsEnabled.value) {
1200
+ emit('error', {
1201
+ message: 'app plugins are off on this instance — deep link ignored',
1202
+ context: { what: 'openAppTarget', plugin: p.plugin, path: p.path },
1203
+ });
1204
+ return false;
1205
+ }
1206
+ await pluginActions.refresh();
1207
+ const node: FileNode =
1208
+ files.value.find((f) => f.path === p.path) ??
1209
+ ({ path: p.path, basename: p.path.split('/').pop() ?? p.path, type: 'file' } as FileNode);
1210
+ if (p.action) {
1211
+ const action = pluginActions.byKey(`plugin:${p.plugin}/${p.action}`);
1212
+ if (!action) {
1213
+ // The server validated this pair when it stored the target, so getting
1214
+ // here means the app was removed, disabled, or is not visible to this
1215
+ // account — all of which are worth a sentence.
1216
+ flashToast(t('plugin.not_applicable'));
1217
+ emit('error', {
1218
+ message: `no such plugin action: ${p.plugin}/${p.action}`,
1219
+ context: { what: 'openAppTarget', path: p.path },
1220
+ });
1221
+ return false;
1222
+ }
1223
+ await onPluginAction(pluginActionKey(action), [node]);
1224
+ return true;
1225
+ }
1226
+ if (!p.view) return false;
1227
+ // A `page` view is never listed in `views[]` (only inspector/home are), so
1228
+ // its placement is learned from an action that opens it. Nothing else
1229
+ // knows, and guessing "page" for everything would take a dialog-sized
1230
+ // screen into a tab of its own.
1231
+ const owner = pluginActions.actions.value.find((a) => a.plugin === p.plugin && a.view === p.view);
1232
+ if (owner && isPagePlacement(owner.view_placement)) {
1233
+ return openPluginPage(owner, [node]);
1234
+ }
1235
+ try {
1236
+ const res = await api.pluginView(p.plugin, p.view, p.path);
1237
+ if (!res?.surface) {
1238
+ flashToast(t('plugin.view.error'));
1239
+ return false;
1240
+ }
1241
+ pluginView.value = { plugin: p.plugin, view: p.view, surface: res.surface, path: p.path };
1242
+ return true;
1243
+ } catch (e) {
1244
+ flashToast((e as Error)?.message || t('plugin.view.error'));
1245
+ return false;
1246
+ }
1247
+ }
1248
+
1249
+ /**
1250
+ * v3 §3.0 — a surface answered "go to this file, and start that screen".
1251
+ *
1252
+ * ⚠⚠ In-app, through `openAppTarget`, rather than by navigating the window
1253
+ * to the deep link: the person is already standing in the explorer, and a
1254
+ * reload would throw away the folder, the selection and the tab strip to
1255
+ * arrive at a screen the explorer can simply open. The deep-link ADDRESS is
1256
+ * the right answer for a frame with no explorer under it (a plugin `page`
1257
+ * view navigates); it is the wrong one here.
1258
+ *
1259
+ * ⚠ The screen that asked is closed first — an answer carrying `done` has
1260
+ * already closed it, and one that does not would otherwise sit over the
1261
+ * screen the person was sent to.
1262
+ *
1263
+ * ⚠ No permission check here, and that is deliberate: the host verified the
1264
+ * screen belongs to the plugin that answered, and the server re-checked the
1265
+ * path against THIS person's rights before the answer left, dropping the
1266
+ * request rather than refusing the screen. A second, weaker copy of that
1267
+ * rule in the browser would be the kind of check that drifts.
1268
+ */
1269
+ async function onSurfaceOpen(plugin: string, req: { path: string; action?: string; view?: string }): Promise<void> {
1270
+ if (!plugin || !req?.path) return;
1271
+ pluginView.value = null;
1272
+ // The folder first, so the person can SEE where they were taken — the same
1273
+ // walk `onOpenOpOutput` makes for a job's output.
1274
+ const sep = req.path.indexOf('://');
1275
+ const storageName = sep === -1 ? '' : req.path.slice(0, sep);
1276
+ const rel = stripAdapter(req.path).replace(/^\/+|\/+$/g, '');
1277
+ const slash = rel.lastIndexOf('/');
1278
+ const dirRel = slash === -1 ? '' : rel.slice(0, slash);
1279
+ const target = multiStorageRoot.value && storageName ? (dirRel ? `${storageName}/${dirRel}` : storageName) : dirRel;
1280
+ try {
1281
+ await load(target);
1282
+ } catch {
1283
+ /* the folder may be gone; the screen below still tries the file */
1284
+ }
1285
+ await openAppTarget({ plugin, action: req.action, view: req.view, path: req.path });
1286
+ }
1287
+
1288
+ /** Cancel from the operations center — the server stops the job. */
1289
+ async function onCancelPendingOp(id: number) {
1290
+ try {
1291
+ await api.opsCancel(id);
1292
+ await pendingOps.poll();
1293
+ } catch {
1294
+ flashToast(t('plugin.cancel_failed'));
1295
+ }
1296
+ }
1297
+
1298
+ /**
1299
+ * "Open" on a finished job: go to the output's folder and select it. The
1300
+ * output is an adapter-qualified path (`docs://reports/nda-signed.pdf`); the
1301
+ * folder is loaded the way a search hit's is, then the row is picked out of
1302
+ * the fresh listing by its exact path.
1303
+ */
1304
+ async function onOpenOpOutput(_id: number, path: string) {
1305
+ const sep = path.indexOf('://');
1306
+ const storageName = sep === -1 ? '' : path.slice(0, sep);
1307
+ const rel = stripAdapter(path).replace(/^\/+|\/+$/g, '');
1308
+ const slash = rel.lastIndexOf('/');
1309
+ const dirRel = slash === -1 ? '' : rel.slice(0, slash);
1310
+ const target = multiStorageRoot.value && storageName ? (dirRel ? `${storageName}/${dirRel}` : storageName) : dirRel;
1311
+ await load(target);
1312
+ const node = files.value.find((f) => f.path === path);
1313
+ if (node) {
1314
+ selection.click(node.path);
1315
+ return;
1316
+ }
1317
+ flashToast(t('plugin.output_missing'));
1318
+ }
1319
+ /* === /App plugins === */
1320
+
826
1321
  // Creative UI state: starred / tags / recently-opened. The component
827
1322
  // helpers (StarButton, TagPicker, RecentlyOpened) handle their own
828
1323
  // API calls — the explorer just tracks the cross-row state needed to
@@ -987,31 +1482,62 @@ function openTagPickerFor(n: FileNode) {
987
1482
  * once the chip has navigated, leaving it up means a dialog about one file
988
1483
  * covering the view of all the others that share its tag.
989
1484
  */
990
- function openTagView(tag: string) {
1485
+ function openTagView(tag: string, kind: TagKind | '' = '') {
991
1486
  showTagPicker.value = false;
992
- void loadTagView(tag);
1487
+ void loadTagView(tag, kind);
993
1488
  }
994
1489
 
995
1490
  function onNodeTagsChanged() {
996
- invalidateTagCache();
997
- void loadNavTags(true);
998
- if (navView.value === 'tag' && navTag.value) void loadTagView(navTag.value);
1491
+ // The cache is already dropped (lib/tags `announceTagsChanged`), so this
1492
+ // re-ask is real — and unforced, so every explorer on the page shares one.
1493
+ void loadNavTags();
1494
+ if (navView.value === 'tag' && navTag.value) void loadTagView(navTag.value, navTagKind.value);
999
1495
  }
1496
+ /* etiket:k2 — the ONE path a tag write reaches the panel by: the module-level
1497
+ * announcement, which survives the picker being closed before the save
1498
+ * answered (a component emit does not) and reaches every explorer on the page.
1499
+ * The picker's own `change` / the details panel's `tags-changed` are no longer
1500
+ * wired here, or each save would reload the list twice. */
1501
+ const offTagsChanged = onTagsChanged(onNodeTagsChanged);
1502
+ onBeforeUnmount(offTagsChanged);
1000
1503
 
1001
1504
  function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
1002
- // RecentlyOpened emits the bare row — synthesize a FileNode shaped
1003
- // enough for openNode to route into the editor / preview.
1004
- const node = {
1005
- type: 'file',
1006
- path: entry.path,
1007
- basename: entry.name,
1008
- extension: (entry.name.split('.').pop() || '').toLowerCase(),
1009
- id: entry.id,
1010
- } as unknown as FileNode;
1505
+ // RecentlyOpened emits the bare row. The same converter Recent / Starred /
1506
+ // a tag / Home use gives openNode a row with its storage-qualified path AND
1507
+ // its own `perm` + `read_only`, so the editor-vs-preview split reads the
1508
+ // row's level, not the folder's. The bare synthesis stays as the fallback
1509
+ // for a row the converter cannot address (no storage name at a
1510
+ // multi-storage root) — it was the only shape until 2026-09-19.
1511
+ const node =
1512
+ nodeRowToFileNode(entry as unknown as Record<string, unknown>) ??
1513
+ ({
1514
+ type: 'file',
1515
+ path: entry.path,
1516
+ basename: entry.name,
1517
+ extension: (entry.name.split('.').pop() || '').toLowerCase(),
1518
+ id: entry.id,
1519
+ } as unknown as FileNode);
1011
1520
  showRecents.value = false;
1012
1521
  openNode(node);
1013
1522
  }
1014
1523
 
1524
+ /**
1525
+ * Right-click on a row of the Recently-opened tray: the SAME menu a listing
1526
+ * row gets (owner: "CONTEXT MENÜ HER YERDE AYNI OLMALI"). The tray's row is
1527
+ * the raw node row the endpoint answers with, so it goes through
1528
+ * `nodeRowToFileNode` like Recent / Starred / a tag / Home do and arrives
1529
+ * with its own `perm` + `read_only`; `onContextTarget` then treats it like a
1530
+ * Home card — a target outside this pane's listing. The tray closes first,
1531
+ * as it does on open: it is a modal over the listing, and the verb the menu
1532
+ * runs (Rename, Move to…) opens a dialog of its own.
1533
+ */
1534
+ function onRecentContext(row: Record<string, unknown>, ev: MouseEvent) {
1535
+ const node = nodeRowToFileNode(row);
1536
+ if (!node) return;
1537
+ showRecents.value = false;
1538
+ void onContextTarget(node, ev);
1539
+ }
1540
+
1015
1541
  // Resolution order for each external viewer: explicit config override → live
1016
1542
  // backend probe. The probe is the source of truth: an operator can flip the
1017
1543
  // service "on" but if last_check failed (state='error') we still hide the
@@ -1025,6 +1551,16 @@ const effectiveOnlyOfficeBase = computed<string | null>(() => {
1025
1551
  return capabilitiesData.value?.onlyoffice_url || null;
1026
1552
  });
1027
1553
 
1554
+ /**
1555
+ * Could this caller SET UP a missing optional service? The server's answer
1556
+ * (`caller_admin` — an administrator who can reach the instance settings;
1557
+ * a tenant admin and an API token cannot), never a role guessed here. It picks
1558
+ * between the owner's two answers for an action that needs a service that is
1559
+ * not there: "disabled with a reason for administrators, hidden for everybody
1560
+ * else" (lib/serviceGate).
1561
+ */
1562
+ const callerAdmin = computed(() => capabilitiesData.value?.caller_admin === true);
1563
+
1028
1564
  const effectiveOnlyOfficeConfigEndpoint = computed<string | null>(() => {
1029
1565
  if (!effectiveOnlyOfficeBase.value) return null;
1030
1566
  return api.endpoints.onlyOfficeConfig || null;
@@ -1041,8 +1577,35 @@ const effectiveDrawioUrl = computed<string | null>(() => {
1041
1577
  // Universal converter (p2r3/convert fork). convert_url is only populated by
1042
1578
  // the backend when the "convert" external service is enabled, so a simple
1043
1579
  // presence check is enough gating.
1044
- const effectiveConvertUrl = computed<string | null>(
1045
- () => props.config.convertBase || capabilitiesData.value?.convert_url || null,
1580
+ const effectiveConvertUrl = computed<string | null>(() => {
1581
+ if (props.config.convertBase) return props.config.convertBase;
1582
+ /* ⚠ Health too, like the two above: the server fills `convert_url` whenever
1583
+ * the service is ENABLED, so a converter that is enabled but unreachable
1584
+ * used to be offered and then sat on "Loading the converter…" for ever. */
1585
+ const ext = capabilitiesData.value?.external?.convert;
1586
+ if (ext && !isExternalUsable(ext)) return null;
1587
+ return capabilitiesData.value?.convert_url || null;
1588
+ });
1589
+
1590
+ /* The legacy converter against the Convert app: one of them, never both
1591
+ * (lib/serviceGate `legacyConvertGate`). `legacyConvertUrl` is what every
1592
+ * door to the iframe dialog reads — the menu, the toolbar, the dialog's own
1593
+ * v-if — so none of them can offer what the rule withheld. */
1594
+ const legacyConvert = computed(() =>
1595
+ legacyConvertGate({
1596
+ appOffered: pluginsEnabled.value && convertAppOffered(pluginActions.actions.value),
1597
+ configured:
1598
+ !!props.config.convertBase ||
1599
+ capabilitiesData.value?.external?.convert?.enabled === true ||
1600
+ !!capabilitiesData.value?.convert_url,
1601
+ healthy: !!effectiveConvertUrl.value,
1602
+ callerAdmin: callerAdmin.value,
1603
+ unhealthyReason: t('ctx.needs_convert'),
1604
+ adminNote: t('convert.legacy_admin'),
1605
+ }),
1606
+ );
1607
+ const legacyConvertUrl = computed<string | null>(() =>
1608
+ legacyConvert.value.hidden || legacyConvert.value.disabled ? null : effectiveConvertUrl.value,
1046
1609
  );
1047
1610
 
1048
1611
  /* belge:n1 — what the SERVER can create, crossed with what WE could open.
@@ -1071,6 +1634,14 @@ const showRename = ref(false);
1071
1634
  const showDelete = ref(false);
1072
1635
  const showPreview = ref(false);
1073
1636
  const renameTarget = ref<FileNode | null>(null);
1637
+ /* Why the last rename did not happen, shown in the dialog. A failure used to be
1638
+ * only EMITTED, which the stock web app logs to the console: the dialog stayed
1639
+ * open and silent, the one answer a person can act on — the name is taken —
1640
+ * unseen. */
1641
+ const renameError = ref<string | null>(null);
1642
+ watch(showRename, (open) => {
1643
+ if (open) renameError.value = null;
1644
+ });
1074
1645
  /* ui-fix — does the open rename/delete/new-folder modal belong to the side
1075
1646
  * pane? (the menu is identical to the main pane's; this routes the mutation
1076
1647
  * to the right one.) */
@@ -1261,12 +1832,23 @@ const sharedStorageNames = ref<string[]>([]);
1261
1832
  * embedder's users had no path to a protocol guide or to the API token those
1262
1833
  * guides tell them to use.
1263
1834
  *
1264
- * ⚠ Not gated on role. The backend decides — ConnectionsPanel renders what the
1265
- * API returns (a non-admin gets the guides and a "why not" card instead of the
1266
- * storage form), and /api/tokens caps every scope against the caller's own role.
1835
+ * ⚠ Not gated on role. The backend decides — ConnectionsPanel is the protocol
1836
+ * guides and the credentials they need, which every caller may read, and
1837
+ * /api/tokens caps every scope against the caller's own role.
1267
1838
  */
1268
1839
  const connectionsEnabled = computed(() => props.config.connections ?? !simpleUi.value);
1269
1840
 
1841
+ /**
1842
+ * paylas:m1 — may the panel draw its "My shares" row?
1843
+ *
1844
+ * ⚠ `=== true`, not `?? <default>`: the row's screen is the HOST's, this
1845
+ * component only announces the intent, and an embedder who has not said yes
1846
+ * has not built that screen. Silence therefore has to mean no — the inverse of
1847
+ * `connectionsEnabled` above, whose panel this component draws itself and can
1848
+ * always deliver on.
1849
+ */
1850
+ const mySharesEnabled = computed(() => props.config.mySharesVisible === true);
1851
+
1270
1852
  /**
1271
1853
  * Is this caller an integration rather than a person (backend migration 00030)?
1272
1854
  *
@@ -1450,6 +2032,19 @@ watch(
1450
2032
  * 2. **The live count**, which is a real query. See `advSearchCount`.
1451
2033
  */
1452
2034
  const showAdvSearch = ref(false);
2035
+ /* etiket:k2 — the dialog offers the person's tags, each with its kind, as
2036
+ picks under the Tags box. Asked for when it opens, through the same
2037
+ module-level cache as the panel's list, so it costs nothing when the panel
2038
+ already has them — and works in an embed that shows no panel at all. */
2039
+ const advKnownTags = ref<TagItem[]>([]);
2040
+ watch(showAdvSearch, async (open) => {
2041
+ if (!open) return;
2042
+ advKnownTags.value = await fetchAllTags({
2043
+ apiBase: props.config.apiBase ?? '',
2044
+ authHeaders: () => buildAuthHeaders(),
2045
+ authCredentials: api.credentialsMode(),
2046
+ });
2047
+ });
1453
2048
  /** The folder the dialog was opened from, frozen for its lifetime. */
1454
2049
  const advPathBase = ref('');
1455
2050
 
@@ -1512,15 +2107,28 @@ function advHitToNode(h: GlobalSearchHit, storageName: string): FileNode {
1512
2107
  };
1513
2108
  }
1514
2109
 
1515
- /** Run one advanced search and hand back the rows, unfiltered. */
1516
- async function advFetchRows(scope: AdvScope, query: string, target: string): Promise<FileNode[]> {
2110
+ /** Run one advanced search and hand back the rows, unfiltered — and whether
2111
+ * the answer was cut. The name scope has the server's own word for that
2112
+ * (`truncated`); /api/files/search's flag does not survive `globalSearch`,
2113
+ * which returns hits only, so the content scope keeps the full-page guess. */
2114
+ async function advFetchRows(
2115
+ scope: AdvScope,
2116
+ query: string,
2117
+ target: string,
2118
+ ): Promise<{ rows: FileNode[]; truncated: boolean }> {
1517
2119
  if (scope === 'name') {
1518
2120
  const resp = await api.search(target, query);
1519
- return filterListing(resp.files);
2121
+ return {
2122
+ rows: filterListing(resp.files),
2123
+ truncated: advSearchTruncated(resp.files.length, MANAGER_SEARCH_PAGE, resp.truncated),
2124
+ };
1520
2125
  }
1521
2126
  const hits = await api.globalSearch(query, { limit: ADV_CONTENT_LIMIT, scope });
1522
2127
  const storageName = adapter.value || (props.config.storages ?? [])[0]?.name || '';
1523
- return filterListing(hits.map((h) => advHitToNode(h, storageName)));
2128
+ return {
2129
+ rows: filterListing(hits.map((h) => advHitToNode(h, storageName))),
2130
+ truncated: advSearchTruncated(hits.length, ADV_CONTENT_LIMIT),
2131
+ };
1524
2132
  }
1525
2133
 
1526
2134
  /** The target `load()` would use for the current position. */
@@ -1543,11 +2151,10 @@ function advTarget(): string {
1543
2151
  */
1544
2152
  async function advSearchCount(req: AdvSearchRequest): Promise<AdvCountResult> {
1545
2153
  const query = advQueryString(req);
1546
- const rows = await advFetchRows(req.scope, query, advTarget());
1547
- const limit = req.scope === 'name' ? 250 : ADV_CONTENT_LIMIT;
2154
+ const { rows, truncated } = await advFetchRows(req.scope, query, advTarget());
1548
2155
  return {
1549
2156
  count: applyFilters(rows, req.filters).length,
1550
- capped: advSearchTruncated(rows.length, limit),
2157
+ capped: truncated,
1551
2158
  };
1552
2159
  }
1553
2160
 
@@ -1593,15 +2200,22 @@ function onToolbarSearch(v: string) {
1593
2200
  * searches what is on screen.
1594
2201
  */
1595
2202
  const driveScopeLabel = computed(() => {
1596
- /* gorunum:v3-shell — Home is not a place to search IN. Its three blocks are
1597
- an overview of everything, so "Search in Home" would name a scope that
1598
- does not exist; the placeholder falls back to the everywhere wording. */
1599
- if (navView.value === 'home') return '';
1600
- if (navView.value === 'tag') return navTag.value;
1601
- if (navView.value) return t(`sidenav.${navView.value}`);
1602
- const rel = currentPath.value.replace(/\/+$/, '');
1603
- const last = rel.split('/').filter(Boolean).pop();
1604
- return last || adapter.value || '';
2203
+ /* ⚠ The field searches the WHOLE storage (`?action=search` filters by
2204
+ storage id only — see handlers/manager.go vfSearch), and at the virtual
2205
+ root every storage. Naming the open FOLDER here ("Search in Photos") was
2206
+ a lie the user found: results came from the whole storage. The scope is
2207
+ the storage; the folder-scoped box is FilterBar's "Filter in this
2208
+ folder…". Home, the virtual root and the cross-storage views (Recent,
2209
+ Starred, Shared, tags) have no single storage → the everywhere wording.
2210
+ ⚠ `multiStorageRoot` is the MODE (the admin explorer is always in it), not
2211
+ the place: inside a storage the scope is that storage. Gating on the mode
2212
+ printed "Search all storages" on every admin folder (caught by
2213
+ e2e/tests/107 on the v0.42.2 chain). */
2214
+ if (navView.value || atVirtualRoot.value) return '';
2215
+ const name = adapter.value;
2216
+ if (!name) return '';
2217
+ const st = (props.config.storages ?? []).find((s) => s.name === name);
2218
+ return st?.label || name;
1605
2219
  });
1606
2220
 
1607
2221
  /**
@@ -1680,70 +2294,19 @@ async function loadQuota() {
1680
2294
  }
1681
2295
 
1682
2296
  /**
1683
- * nodeRowToFileNode — the starred / recently-opened endpoints answer with raw
1684
- * node rows (relative `path`, numeric `storage_id`), not the listing shape.
1685
- *
1686
- * The storage NAME is what a qualified path needs, and a node row does not
1687
- * carry the id-to-name mapping. The backend fills `storage` for exactly this
1688
- * (handlers/shared.go, attachStorageNames); against an older server the only
1689
- * safe fallback is the single-storage case — guessing in a multi-storage
1690
- * install sends the user to a path in somebody else's drive.
2297
+ * nodeRowToFileNode — the starred / recently-opened / tag endpoints answer
2298
+ * with raw node rows (relative `path`, numeric `storage_id`), not the listing
2299
+ * shape. The conversion itself lives in `lib/nodeRow` (pure, unit-tested);
2300
+ * this binds it to the explorer's config. Every view that lists such rows —
2301
+ * Recent, Starred, a tag, the Home cards, the Recently-opened tray — goes
2302
+ * through here, so they all carry the same `perm` + `read_only` the context
2303
+ * menu gates its write verbs on.
1691
2304
  */
1692
- /** tablo:t1 — an RFC3339 stamp from a node row as unix ms, or undefined. A
1693
- * string we cannot parse is left undefined rather than turned into `NaN`,
1694
- * which would print as "Invalid Date" and sort unpredictably. */
1695
- function rowMillis(v: unknown): number | undefined {
1696
- if (typeof v !== 'string' || !v) return undefined;
1697
- const ms = Date.parse(v);
1698
- return Number.isFinite(ms) ? ms : undefined;
1699
- }
1700
-
1701
2305
  function nodeRowToFileNode(row: Record<string, unknown>): FileNode | null {
1702
- const rel = String(row?.path ?? '').replace(/^\/+/, '');
1703
- if (!rel) return null;
1704
- const configured = props.config.storages ?? [];
1705
- const storageName =
1706
- typeof row.storage === 'string' && row.storage
1707
- ? row.storage
1708
- : configured.length === 1
1709
- ? configured[0].name
1710
- : '';
1711
- if (multiStorageRoot.value && !storageName) return null;
1712
- const name = String(row.name ?? rel.split('/').pop() ?? '');
1713
- const isDir = row.type === 'dir';
1714
- const size = typeof row.size === 'number' ? row.size : 0;
1715
- const id = typeof row.id === 'number' ? row.id : undefined;
1716
- return {
1717
- type: isDir ? 'dir' : 'file',
1718
- id,
1719
- /* tablo:t1 — ⚠⚠ THE DATE. A node row carries `backend_mtime` (what the
1720
- storage says) and `db_mtime` (what our last scan recorded) as RFC3339
1721
- strings; `FileNode.last_modified` is unix MILLISECONDS. Nothing mapped
1722
- between the two, so every row from Recent, Starred and a tag view
1723
- arrived with no date at all — measured on Recent: eleven rows, eleven em
1724
- dashes in the Modified column, and a Modified column header you could
1725
- click that then sorted nothing. It also made the date grouping this view
1726
- is supposed to show impossible, because every row fell in the "No date"
1727
- bucket. Storage first: `backend_mtime` is the file's own truth and
1728
- `db_mtime` only says when we last looked at it. */
1729
- last_modified: rowMillis(row.backend_mtime) ?? rowMillis(row.db_mtime),
1730
- path: storageName ? `${storageName}://${rel}` : rel,
1731
- basename: name,
1732
- extension: isDir
1733
- ? ''
1734
- : name.includes('.')
1735
- ? (name.split('.').pop() || '').toLowerCase()
1736
- : '',
1737
- storage: storageName,
1738
- visibility: 'private',
1739
- size,
1740
- file_size: size,
1741
- mime_type: typeof row.mime === 'string' ? row.mime : '',
1742
- // Keyed by node id. A file with no rendered thumbnail 404s here and the
1743
- // view falls back to its icon — the contract the ordinary listing has too.
1744
- thumb_url: !isDir && id !== undefined ? `/api/files/thumb/${id}` : undefined,
1745
- extra_metadata: {},
1746
- } as unknown as FileNode;
2306
+ return nodeRowToFileNodePure(row, {
2307
+ storages: props.config.storages ?? [],
2308
+ multiStorageRoot: multiStorageRoot.value,
2309
+ });
1747
2310
  }
1748
2311
 
1749
2312
  /** GET one of the view endpoints. Returns rows already in listing shape. */
@@ -1831,8 +2394,10 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1831
2394
  if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
1832
2395
  navView.value = 'home';
1833
2396
  navTag.value = '';
2397
+ navTagKind.value = '';
1834
2398
  trashMode.value = false;
1835
2399
  e2eRoot.value = '';
2400
+ forgetFolderPerm();
1836
2401
  selection.clear();
1837
2402
  files.value = [];
1838
2403
  dirname.value = NAV_VIEW_DIRNAME.home;
@@ -1843,7 +2408,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1843
2408
  }
1844
2409
  if (kind === 'tag') {
1845
2410
  // The tag view needs a name; the panel calls loadTagView directly.
1846
- if (navTag.value) await loadTagView(navTag.value);
2411
+ if (navTag.value) await loadTagView(navTag.value, navTagKind.value);
1847
2412
  return;
1848
2413
  }
1849
2414
  if (kind === 'trash') {
@@ -1855,6 +2420,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1855
2420
  // never lights up.
1856
2421
  navView.value = 'trash';
1857
2422
  navTag.value = '';
2423
+ navTagKind.value = '';
1858
2424
  return;
1859
2425
  }
1860
2426
  loading.value = true;
@@ -1864,8 +2430,10 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1864
2430
  if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
1865
2431
  navView.value = kind;
1866
2432
  navTag.value = '';
2433
+ navTagKind.value = '';
1867
2434
  trashMode.value = false;
1868
2435
  e2eRoot.value = '';
2436
+ forgetFolderPerm();
1869
2437
  selection.clear();
1870
2438
  try {
1871
2439
  files.value = await fetchNavRows(kind);
@@ -1896,7 +2464,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1896
2464
  * hash — goes through `virtualSegmentLabel`, so none of them can print the
1897
2465
  * sentinel the way the strip once printed `.shared`.
1898
2466
  */
1899
- async function loadTagView(tag: string) {
2467
+ async function loadTagView(tag: string, kind: TagKind | '' = '') {
1900
2468
  closeNavDrawer();
1901
2469
  const name = String(tag ?? '').trim();
1902
2470
  if (!name) return;
@@ -1904,17 +2472,26 @@ async function loadTagView(tag: string) {
1904
2472
  if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
1905
2473
  navView.value = 'tag';
1906
2474
  navTag.value = name;
2475
+ navTagKind.value = kind;
1907
2476
  trashMode.value = false;
1908
2477
  e2eRoot.value = '';
2478
+ forgetFolderPerm();
1909
2479
  selection.clear();
1910
2480
  try {
1911
- const rows = await fetchTaggedRows(name, {
1912
- apiBase: props.config.apiBase ?? '',
1913
- authHeaders: () => buildAuthHeaders(),
1914
- authCredentials: api.credentialsMode(),
1915
- });
2481
+ const rows = await fetchTaggedRows(
2482
+ name,
2483
+ {
2484
+ apiBase: props.config.apiBase ?? '',
2485
+ authHeaders: () => buildAuthHeaders(),
2486
+ authCredentials: api.credentialsMode(),
2487
+ },
2488
+ 200,
2489
+ kind,
2490
+ );
1916
2491
  files.value = rows.map(nodeRowToFileNode).filter((n): n is FileNode => n !== null);
1917
- const seg = makeTagSegment(name);
2492
+ // etiket:k2 — the kind is part of the address (`.mytag~x` / `.teamtag~x`),
2493
+ // so a reload or a copied link opens the same one of two same-named tags.
2494
+ const seg = makeTagSegment(name, kind);
1918
2495
  dirname.value = seg;
1919
2496
  currentPath.value = seg;
1920
2497
  // Spans every storage, like Starred/Recent/Shared — so no storage crumb.
@@ -1922,7 +2499,7 @@ async function loadTagView(tag: string) {
1922
2499
  } catch (err) {
1923
2500
  const msg = err instanceof Error ? err.message : String(err);
1924
2501
  files.value = [];
1925
- emit('error', { message: msg, context: { op: `nav-view:tag:${name}` } });
2502
+ emit('error', { message: msg, context: { op: `nav-view:tag:${kind ? `${kind}:` : ''}${name}` } });
1926
2503
  flashToast(msg);
1927
2504
  } finally {
1928
2505
  loading.value = false;
@@ -1941,7 +2518,7 @@ async function loadTagView(tag: string) {
1941
2518
  * none. The cache is dropped the instant the user edits tags, which is the
1942
2519
  * only staleness anybody can notice.
1943
2520
  */
1944
- const navTags = ref<string[]>([]);
2521
+ const navTags = ref<TagItem[]>([]);
1945
2522
  const navTagsLoaded = ref(false);
1946
2523
 
1947
2524
  async function loadNavTags(force = false) {
@@ -2054,11 +2631,83 @@ function permCanEdit(p: string | undefined): boolean {
2054
2631
  function permIsOwner(p: string | undefined): boolean {
2055
2632
  return p === 'owner';
2056
2633
  }
2057
- // Effective perm for a selection: a single entry's own perm (falls back to the
2058
- // directory perm), else the directory perm for multi-select / background.
2059
- function selPerm(sel: FileNode[]): string {
2060
- if (sel.length === 1 && typeof sel[0]?.perm === 'string') return sel[0].perm as string;
2061
- return dirPerm.value;
2634
+ /* ⚠⚠ THE CONTEXT MENU MUST BE THE SAME EVERYWHERE (owner, 2026-09-19: "son
2635
+ * kullanılanlar, ana sayfa gibi sayfalarda context menu eksik kalıyor.
2636
+ * CONTEXT MENÜ HER YERDE AYNI OLMALI").
2637
+ *
2638
+ * `dirPerm` / `dirReadOnly` describe the FOLDER being listed, and only
2639
+ * `load()` sets them. Recent, Starred, Shared, a tag view and Home list rows
2640
+ * from every storage and have no folder — so whatever folder was open LAST
2641
+ * answered for them. On the landing page that is no folder at all
2642
+ * (`dirPerm === ''`), and `permCanEdit('')` is false, so the menu on a Home
2643
+ * card or a Recent row came up without Rename / Delete / Move to / Share.
2644
+ * After a writable folder had been visited the stale level said yes to
2645
+ * everything, including rows on a read-only mount. Intermittent, therefore
2646
+ * reported as "eksik kalıyor" rather than "yok".
2647
+ *
2648
+ * Three rules restore one menu:
2649
+ * 1. entering a virtual view forgets the folder (`forgetFolderPerm`), so
2650
+ * nothing stale can leak into it;
2651
+ * 2. a row answers for itself — its own `perm` (every listing carries it
2652
+ * now, `handlers/meta.go`) and its own storage's `read_only`;
2653
+ * 3. a multi-selection is as weak as its weakest row, because the server
2654
+ * refuses the whole batch on the one row the caller may not touch.
2655
+ * What stays hidden in a virtual view is only what is structurally
2656
+ * impossible there — New folder, Upload, Paste need a destination folder
2657
+ * (`atVirtualRoot`).
2658
+ */
2659
+ function forgetFolderPerm() {
2660
+ dirPerm.value = '';
2661
+ dirReadOnly.value = false;
2662
+ }
2663
+ /** A view whose rows span every storage: no folder is behind it. */
2664
+ const inVirtualView = computed(() => !!navView.value && navView.value !== 'trash');
2665
+ /** The row's own level; the folder's when the row has none. In a virtual view
2666
+ * a row without a level (a server older than `handlers/meta.go`'s `perm`)
2667
+ * is ungated — the server enforces, this only shapes the menu — rather than
2668
+ * gated by an empty folder level that would hide every write verb. */
2669
+ function rowPerm(n: FileNode): string | undefined {
2670
+ if (typeof n.perm === 'string') return n.perm;
2671
+ return inVirtualView.value ? undefined : dirPerm.value;
2672
+ }
2673
+ /** The row sits on a read-only storage: its own `read_only` (nav/tag rows),
2674
+ * else the host's storage list — a folder listing's rows carry no flag of
2675
+ * their own because `dirReadOnly` already answers for the whole folder. */
2676
+ function nodeReadOnly(n: FileNode): boolean {
2677
+ if (n.read_only === true) return true;
2678
+ const storage = typeof n.storage === 'string' ? n.storage : '';
2679
+ if (!storage) return false;
2680
+ return (props.config.storages ?? []).some((s) => s.name === storage && s.readOnly === true);
2681
+ }
2682
+ /** May the caller write THIS row — level and read-only mount folded together.
2683
+ * The one predicate behind the editor/preview split and the menu's write
2684
+ * verbs, so a Home card and a listing row cannot disagree. */
2685
+ function nodeCanEdit(n: FileNode): boolean {
2686
+ return permCanEdit(rowPerm(n)) && !nodeReadOnly(n);
2687
+ }
2688
+ const PERM_RANK: Record<string, number> = { none: 0, viewer: 1, editor: 2, owner: 3 };
2689
+ // Effective perm for a selection: a single entry's own perm, the WEAKEST of a
2690
+ // multi-selection's own perms, else the directory perm (background / rows
2691
+ // that carry no level).
2692
+ function selPerm(sel: FileNode[]): string | undefined {
2693
+ if (sel.length === 0) return dirPerm.value;
2694
+ let weakest: string | undefined;
2695
+ let ungated = false;
2696
+ for (const n of sel) {
2697
+ const p = rowPerm(n);
2698
+ if (p === undefined) {
2699
+ ungated = true;
2700
+ continue;
2701
+ }
2702
+ if (weakest === undefined || (PERM_RANK[p] ?? 0) < (PERM_RANK[weakest] ?? 0)) weakest = p;
2703
+ }
2704
+ // An ungated row (no level, unwired ACL) never strengthens a selection
2705
+ // that also holds a gated one; alone, it stays ungated.
2706
+ return weakest ?? (ungated ? undefined : dirPerm.value);
2707
+ }
2708
+ /** Any row of the selection on a read-only storage → the selection is. */
2709
+ function selReadOnly(sel: FileNode[]): boolean {
2710
+ return sel.some(nodeReadOnly);
2062
2711
  }
2063
2712
  // Can the current user write into the directory being viewed? Gates the
2064
2713
  // toolbar New Folder / Upload / Paste + drag-drop upload.
@@ -2236,6 +2885,9 @@ function refreshAll() {
2236
2885
  }
2237
2886
 
2238
2887
  async function load(path?: string) {
2888
+ // Whatever an earlier search said about ITS answer, this listing has not
2889
+ // answered yet (the "more results than shown" strip reads this).
2890
+ searchTruncated.value = false;
2239
2891
  /* === etiket:t1 — a sentinel is a VIEW, not a folder ===================
2240
2892
  * A restored tab, a reload on `#.trash` / `#.starred` / `#.tag~invoices`,
2241
2893
  * or the breadcrumb crumb for the view you are standing in all arrive here
@@ -2274,7 +2926,7 @@ async function load(path?: string) {
2274
2926
  writePersistedPath('');
2275
2927
  return await load('');
2276
2928
  }
2277
- if (asView.kind === 'tag') await loadTagView(asView.tag);
2929
+ if (asView.kind === 'tag') await loadTagView(asView.tag, asView.tagKind);
2278
2930
  else await loadNavView(asView.kind);
2279
2931
  return;
2280
2932
  }
@@ -2310,6 +2962,7 @@ async function load(path?: string) {
2310
2962
  folder listing. */
2311
2963
  navView.value = '';
2312
2964
  navTag.value = '';
2965
+ navTagKind.value = '';
2313
2966
  let requested = path ?? currentPath.value ?? '';
2314
2967
  try {
2315
2968
  notFoundPath.value = '';
@@ -2348,17 +3001,24 @@ async function load(path?: string) {
2348
3001
  here. Everything downstream — the views, the selection, the inspector —
2349
3002
  sees ordinary rows, which is the point: one results surface. */
2350
3003
  const advContent = !!searchQuery.value && advScope.value !== 'name';
2351
- const resp: ManagerResponse = advContent
3004
+ const adv = advContent ? await advFetchRows(advScope.value, searchQuery.value, target) : null;
3005
+ const resp: ManagerResponse = adv
2352
3006
  ? {
2353
3007
  adapter: adapter.value,
2354
3008
  storages: (props.config.storages ?? []).map((s) => s.name),
2355
3009
  dirname: dirname.value,
2356
3010
  read_only: false,
2357
- files: await advFetchRows(advScope.value, searchQuery.value, target),
3011
+ files: adv.rows,
3012
+ truncated: adv.truncated,
2358
3013
  }
2359
3014
  : searchQuery.value
2360
3015
  ? await api.search(target, searchQuery.value)
2361
3016
  : await api.index(target);
3017
+ // A search that matched more than it returned says so (banner strip). The
3018
+ // server's `truncated` is the answer; an older server that does not send
3019
+ // it leaves the full-page guess the advanced search count always made.
3020
+ searchTruncated.value =
3021
+ !!searchQuery.value && advSearchTruncated(resp.files.length, MANAGER_SEARCH_PAGE, resp.truncated);
2362
3022
  adapter.value = resp.adapter;
2363
3023
  dirname.value = resp.dirname;
2364
3024
  dirPerm.value = (resp.perm as string) || '';
@@ -2468,6 +3128,14 @@ const trashRetentionDays = ref<number | null>(null);
2468
3128
  const trashCanEmpty = ref(false);
2469
3129
  const trashEmptying = ref(false);
2470
3130
  const showTrashConfirm = ref(false);
3131
+ /** The purge being followed, while the server reports it as running. */
3132
+ const trashEmptyRun = ref<TrashEmptyStatus | null>(null);
3133
+ /* Set when the explorer is taken down: it stops the following — the purge
3134
+ * is the server's and carries on. */
3135
+ let trashEmptyUnwatched = false;
3136
+ onBeforeUnmount(() => {
3137
+ trashEmptyUnwatched = true;
3138
+ });
2471
3139
 
2472
3140
  async function probeTrashPolicy() {
2473
3141
  trashRetentionDays.value = null;
@@ -2516,24 +3184,73 @@ const trashConfirmKey = computed(() =>
2516
3184
  trashSizeKnown.value ? 'trash.empty_confirm_body' : 'trash.empty_confirm_body_nosize',
2517
3185
  );
2518
3186
 
3187
+ /** "Emptying the trash… 120 of 61,844" while a purge is followed. */
3188
+ const trashEmptyProgress = computed(() => {
3189
+ const run = trashEmptyRun.value;
3190
+ if (!run?.running) return '';
3191
+ if (run.queued) return t('trash.emptying_queued');
3192
+ const nf = new Intl.NumberFormat(localeTag(locale.value));
3193
+ return t('trash.emptying', { done: nf.format(run.scanned ?? 0), total: nf.format(run.total ?? 0) });
3194
+ });
3195
+
3196
+ /* ⚠⚠ A 2xx is not "emptied" any more. The endpoint used to purge inside the
3197
+ * request, so a large trash never answered — nginx's 504 at sixty seconds
3198
+ * became a toast reading "504". It now answers within seconds: the final
3199
+ * counts, or 202 while the purge goes on in the background, which
3200
+ * lib/trashEmpty follows on GET until the run ends. */
2519
3201
  async function emptyTrash() {
2520
3202
  showTrashConfirm.value = false;
2521
3203
  if (!trashCanEmpty.value || trashEmptying.value) return;
2522
3204
  trashEmptying.value = true;
3205
+ const url = `${props.config.apiBase ?? ''}/api/admin/trash/empty`;
2523
3206
  try {
2524
- const res = await fetch(`${props.config.apiBase ?? ''}/api/admin/trash/empty`, {
2525
- method: 'POST',
2526
- headers: await buildAuthHeaders(),
2527
- credentials: api.credentialsMode(),
3207
+ const end = await emptyTrashAndFollow({
3208
+ start: async () =>
3209
+ fetch(url, { method: 'POST', headers: await buildAuthHeaders(), credentials: api.credentialsMode() }),
3210
+ status: async () => fetch(url, { headers: await buildAuthHeaders(), credentials: api.credentialsMode() }),
3211
+ /* The run is an ops row: it goes into the operations centre (progress,
3212
+ * Cancel) the moment the server names it — a run done within the wait
3213
+ * as well, like any other operation the person started. */
3214
+ onStart: (run) => {
3215
+ if (!run.op_id) return;
3216
+ pendingOps.register({
3217
+ id: run.op_id,
3218
+ kind: 'trash-empty',
3219
+ status: run.running ? (run.queued ? 'pending' : 'running') : run.cancelled ? 'cancelled' : 'ok',
3220
+ total: run.total ?? 0,
3221
+ done: run.scanned ?? 0,
3222
+ });
3223
+ },
3224
+ onProgress: (run) => {
3225
+ trashEmptyRun.value = run;
3226
+ },
3227
+ onBusy: () => flashToast(t('trash.empty_busy')),
3228
+ stopped: () => trashEmptyUnwatched,
2528
3229
  });
2529
- if (!res.ok) throw new Error(String(res.status));
2530
- await loadTrash();
2531
- flashToast(t('trash.emptied'));
3230
+ if (end === null) return;
3231
+ if (end.error) throw new Error(t('trash.empty_stopped', { error: end.error }));
3232
+ /* Only the view that is still showing the trash is redrawn: somebody who
3233
+ * went on to a folder while it ran is not pulled back into the trash. */
3234
+ if (trashMode.value) await loadTrash();
3235
+ /* `{running: false}` with no start is a run the server no longer knows —
3236
+ * it restarted under it. The listing just reloaded says what is left, and
3237
+ * nothing is claimed about the rest. */
3238
+ if (!end.started_at) return;
3239
+ if (end.cancelled) {
3240
+ flashToast(t('trash.empty_cancelled', { count: end.purged ?? 0 }));
3241
+ return;
3242
+ }
3243
+ flashToast(end.failed ? t('trash.emptied_partly', { count: end.failed }) : t('trash.emptied'));
2532
3244
  } catch (err) {
3245
+ if (err instanceof TrashEmptyBusy) {
3246
+ flashToast(t('trash.empty_busy'));
3247
+ return;
3248
+ }
2533
3249
  const msg = err instanceof Error ? err.message : String(err);
2534
3250
  emit('error', { message: msg, context: { op: 'trash:empty' } });
2535
3251
  flashToast(msg);
2536
3252
  } finally {
3253
+ trashEmptyRun.value = null;
2537
3254
  trashEmptying.value = false;
2538
3255
  }
2539
3256
  }
@@ -2551,7 +3268,10 @@ async function loadTrash() {
2551
3268
  ({
2552
3269
  type: 'file',
2553
3270
  id: e.id,
2554
- path: e.storage_name ? `${e.storage_name}://${e.path}` : e.path,
3271
+ /* ⚠ The ORIGINAL path, which the server sends with its leading
3272
+ slash: `depo:///x.txt` put an empty segment in the Location
3273
+ column ("depo/"), so it is dropped here. */
3274
+ path: e.storage_name ? `${e.storage_name}://${e.path.replace(/^\/+/, '')}` : e.path,
2555
3275
  basename: e.name,
2556
3276
  extension: e.name.includes('.') ? e.name.split('.').pop() || '' : '',
2557
3277
  storage: e.storage_name || '',
@@ -2564,6 +3284,10 @@ async function loadTrash() {
2564
3284
  size: e.size,
2565
3285
  file_size: e.size,
2566
3286
  mime_type: e.mime || '',
3287
+ /* The date the Trash's date column prints and sorts by — WHEN it was
3288
+ deleted. A trashed row has no modification date to show, and the
3289
+ column read "—" for every row. */
3290
+ last_modified: Date.parse(e.deleted_at) || undefined,
2567
3291
  extra_metadata: { deleted_at: e.deleted_at, ttl_days: e.ttl_days ?? null },
2568
3292
  }) as unknown as FileNode,
2569
3293
  );
@@ -2803,7 +3527,12 @@ watch(currentPath, (p) => {
2803
3527
  // Let a host force a soft re-fetch of the current folder (reusing the existing
2804
3528
  // list-fetch) — used by the realtime layer to refresh on live change events
2805
3529
  // without a full component remount.
2806
- defineExpose({ reload: () => load() });
3530
+ defineExpose({
3531
+ reload: () => load(),
3532
+ /* App plugins — a notification's `target.open` lands here (see the
3533
+ function's own note for why this is a method and not a prop). */
3534
+ openAppTarget,
3535
+ });
2807
3536
 
2808
3537
  onMounted(async () => {
2809
3538
  // Eagerly start fetching Monaco — the user doesn't pay for it
@@ -2849,16 +3578,10 @@ onMounted(async () => {
2849
3578
  if (api.endpoints.opsList) {
2850
3579
  pendingOps.startPolling();
2851
3580
  }
2852
- if (api.endpoints.capabilities) {
2853
- api
2854
- .capabilities()
2855
- .then((cap) => {
2856
- capabilitiesData.value = cap;
2857
- })
2858
- .catch(() => {
2859
- /* swallow — `onlyoffice_url` falls back to null */
2860
- });
2861
- }
3581
+ // ⚠ Not awaited here — the listing must not wait on it — but tracked, so a
3582
+ // caller that genuinely needs the answer (openAppTarget) can await the same
3583
+ // promise instead of reading a ref that may not be filled yet.
3584
+ void loadCapabilities();
2862
3585
  });
2863
3586
 
2864
3587
  // --------------------------------------------------------------------
@@ -3081,6 +3804,29 @@ function openNode(n: FileNode) {
3081
3804
  void loadTrash();
3082
3805
  return;
3083
3806
  }
3807
+ /* ⚠⚠ issue #34 — A SYMLINK THE SERVER WILL NOT FOLLOW IS REFUSED HERE,
3808
+ OUT LOUD, and this is the ONE funnel every way of opening a row passes
3809
+ through (double-click, Enter, the menu's Open, the command palette, a
3810
+ deep link's auto-open), so the answer is the same everywhere.
3811
+
3812
+ The reported bug was the silence: an out-of-root link arrived looking like
3813
+ an ordinary 0-byte file, and clicking it did nothing at all. Letting the
3814
+ request go instead is barely an improvement — the driver's containment
3815
+ refusal surfaces as a generic failure, which reads as "filex is broken"
3816
+ rather than "this boundary is a setting somebody chose". So: no request,
3817
+ and a toast carrying the sentence from lib/symlink, which is the same
3818
+ sentence the row's badge and the details panel already show. Nothing is
3819
+ lost by not asking; the server would refuse it too, and only this side
3820
+ knows the wording that names the setting. */
3821
+ const link = linkWordsFor(n, { t });
3822
+ if (link) {
3823
+ /* ⚠ 8 seconds, not flashToast's 2.5: this is a two-clause explanation
3824
+ that names a setting, and a sentence nobody finishes reading is the
3825
+ silence again with extra steps. Same budget undoToast uses for the
3826
+ other toast people are expected to act on. */
3827
+ showToast({ message: link.why }, 8000);
3828
+ return;
3829
+ }
3084
3830
  if (n.type === 'dir') {
3085
3831
  // Multi-storage virtual rows have a bare path (`s3-test`); pass
3086
3832
  // them straight to load() which will treat them as the wire form
@@ -3134,7 +3880,7 @@ function openNode(n: FileNode) {
3134
3880
  const ext = (n.extension || '').toLowerCase();
3135
3881
  // RBAC: viewers (no edit on this item) always get the read-only preview
3136
3882
  // modal — never the editable surface. This is the "view vs edit" split.
3137
- previewMode.value = permCanEdit((n.perm as string) ?? dirPerm.value)
3883
+ previewMode.value = nodeCanEdit(n)
3138
3884
  ? previewModeForExt(ext)
3139
3885
  : 'view';
3140
3886
  previewTarget.value = n;
@@ -3236,7 +3982,7 @@ function openNodeInNewTab(n: FileNode) {
3236
3982
  }
3237
3983
  // RBAC: a viewer (no edit on this item) can't use the editable "Aç"
3238
3984
  // surface — drop to the read-only in-page preview instead.
3239
- if (!permCanEdit((n.perm as string) ?? dirPerm.value)) {
3985
+ if (!nodeCanEdit(n)) {
3240
3986
  previewNode(n);
3241
3987
  return;
3242
3988
  }
@@ -3655,7 +4401,7 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3655
4401
  ];
3656
4402
  }
3657
4403
  const isFile = single && sel[0]?.type === 'file';
3658
- const tagsLabel = locale.value === 'en' ? 'Tags…' : 'Etiketler…';
4404
+ const tagsLabel = t('ctx.tags_menu');
3659
4405
  const singleHasId = single && typeof sel[0]?.id === 'number';
3660
4406
  /* yildiz:s1 */
3661
4407
  const canStar = starableNodes(sel).length > 0;
@@ -3663,15 +4409,36 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3663
4409
  // RBAC: gate mutating actions when the caller lacks edit on the target. The
3664
4410
  // "İzinler" (permissions) action shows only for owners on RBAC-on storages.
3665
4411
  const p = selPerm(sel);
3666
- const w = permCanEdit(p); // may write here
4412
+ // May write to the selection: the weakest row's level, and no row on a
4413
+ // read-only mount — `selReadOnly` is what `dirReadOnly` cannot answer in a
4414
+ // view whose rows come from several storages (Recent, Starred, a tag, Home).
4415
+ const w = permCanEdit(p) && !selReadOnly(sel);
3667
4416
  // Unified "Paylaş / İzinler" popup: public share link (editor+) + per-user
3668
4417
  // permissions (owner-only, decided inside the modal).
3669
4418
  // Unified "Paylaş / İzinler" popup carries the public share link, per-user
3670
4419
  // permissions AND the folder-only "Dosya İste" (file-drop) tab — the user
3671
4420
  // picks the action from inside the modal, so there's no separate button.
3672
- const accessLabel = locale.value === 'en' ? 'Share / Permissions' : 'Paylaş / İzinler';
4421
+ const accessLabel = t('shortcuts.share'); // the same words the shortcut list uses
4422
+ /* ⚠ "Open" on an office document IS "open in ONLYOFFICE": every path it
4423
+ * takes (the standalone editor tab, the desktop's host window, the in-page
4424
+ * modal) ends at the document server. With none configured it used to open
4425
+ * a tab that printed `Config fetch 503: {"error":"onlyoffice not
4426
+ * configured"}`. The owner's rule (2026-09-21): an administrator sees it
4427
+ * greyed with where to set it up; everybody else is not offered it. Preview
4428
+ * stays — it says the same thing in words and offers the download. */
4429
+ const openExt = String(sel[0]?.extension ?? '').toLowerCase();
4430
+ const openGate = !isFile
4431
+ ? {}
4432
+ : isOfficeExt(openExt)
4433
+ ? gateOnService(!!effectiveOnlyOfficeBase.value, callerAdmin.value, t('ctx.needs_onlyoffice'))
4434
+ : openExt === 'drawio' || openExt === 'dio'
4435
+ ? gateOnService(!!effectiveDrawioUrl.value, callerAdmin.value, t('ctx.needs_drawio'))
4436
+ : {};
4437
+ /* The legacy converter: configured-and-healthy is the only state in which
4438
+ it is offered as working, and never beside the Convert app. */
4439
+ const convertGate = legacyConvert.value;
3673
4440
  return [
3674
- { key: 'open', label: t('ctx.open'), hidden: !single },
4441
+ { key: 'open', label: t('ctx.open'), ...openGate, hidden: !single || openGate.hidden === true },
3675
4442
  { key: 'open-tab', label: t('ctx.open_new_tab'), hidden: !single || sel[0]?.type !== 'dir' } /* wiring:d1 — open the folder in a new tab */,
3676
4443
  { key: 'preview', label: t('ctx.preview'), hidden: !single, disabled: !isFile },
3677
4444
  /* tasi:m1 — Download works on ANY selection now. It used to disappear the
@@ -3683,7 +4450,13 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3683
4450
  decrypted IN THE BROWSER, one file at a time, and the server has no
3684
4451
  plaintext to zip. */
3685
4452
  { key: 'download', label: t('ctx.download'), hidden: !any || (e2eActive.value && !single), disabled: !any },
3686
- { key: 'convert', label: t('ctx.convert'), hidden: !single || !effectiveConvertUrl.value || !w || e2eActive.value /* wiring:e2 — convert is meaningless on ciphertext */, disabled: !isFile },
4453
+ {
4454
+ key: 'convert',
4455
+ label: t('ctx.convert'),
4456
+ title: convertGate.title,
4457
+ hidden: !single || convertGate.hidden === true || !w || e2eActive.value /* wiring:e2 — convert is meaningless on ciphertext */,
4458
+ disabled: !isFile || convertGate.disabled === true,
4459
+ },
3687
4460
  /* tasi:m1 — VISIBLE and grey above a multi-selection, not gone. Sharing
3688
4461
  really is one item at a time (a share link addresses one node), and the
3689
4462
  row now says so in its tooltip; vanishing taught the reader that filex
@@ -3716,7 +4489,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3716
4489
  They borrow the clipboard verbs' marks, which is what they are. */
3717
4490
  { key: 'move-to', label: t('ctx.move_to'), icon: 'cut', hidden: !any || !w, disabled: !any },
3718
4491
  { key: 'copy-to', label: t('ctx.copy_to'), icon: 'copy', hidden: !any, disabled: !any },
3719
- { key: 'paste', label: t('ctx.paste'), hidden: !w, disabled: !clipboard.value.mode },
4492
+ /* Paste needs a destination folder; a virtual view (Recent / Starred /
4493
+ Shared / a tag / Home) has none, so it is the one write verb the row
4494
+ menu does not carry there — every per-row verb above and below does. */
4495
+ { key: 'paste', label: t('ctx.paste'), hidden: !w || atVirtualRoot.value, disabled: !clipboard.value.mode },
3720
4496
  { divider: true, key: 'sep-meta', label: '', hidden: !singleHasId && !canStar },
3721
4497
  /* yildiz:s1 — "star must be an action, like a tag" (owner, v0.30.0).
3722
4498
  Beside Tags on purpose: they are the same kind of verb, and this is the
@@ -3729,6 +4505,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3729
4505
  hidden: !canStar,
3730
4506
  },
3731
4507
  { key: 'tags', label: tagsLabel, hidden: !singleHasId, disabled: !singleHasId },
4508
+ /* App plugins — one row per action whose `applies` rule accepts the
4509
+ selection, under its own `sep-plugins` divider; `[]` when the feature
4510
+ is off, in the trash, or inside an encrypted folder (lib/pluginMenu). */
4511
+ ...pluginActionRows(sel),
3732
4512
  ...keepActionsFor(sel),
3733
4513
  { divider: true, key: 'sep2', label: '', hidden: !w },
3734
4514
  { key: 'delete', label: t('ctx.delete'), danger: true, hidden: !any || !w, disabled: !any },
@@ -3799,6 +4579,12 @@ function actingInPane(): boolean {
3799
4579
  }
3800
4580
 
3801
4581
  async function dispatchItemAction(key: string, targets: FileNode[]) {
4582
+ /* App plugins — `plugin:<plugin>/<action>` rows are not in the switch: the
4583
+ set is whatever the server listed, so they are resolved by prefix. */
4584
+ if (isPluginActionKey(key)) {
4585
+ await onPluginAction(key, targets);
4586
+ return;
4587
+ }
3802
4588
  switch (key) {
3803
4589
  /* gorunum:v1 — the selection bar's × . It used to be delivered by
3804
4590
  * synthesising a click on the listing's background, because nothing here
@@ -3878,7 +4664,7 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
3878
4664
  if (targets[0] && typeof targets[0].id === 'number') {
3879
4665
  const id = targets[0].id;
3880
4666
  navigator.clipboard?.writeText(String(id)).then(
3881
- () => flashToast(locale.value === 'en' ? `Node id ${id} copied` : `Node id ${id} kopyalandı`),
4667
+ () => flashToast(t('toast.node_id_copied', { id })),
3882
4668
  () => flashToast(`#${id}`),
3883
4669
  );
3884
4670
  }
@@ -3999,7 +4785,7 @@ async function paste() {
3999
4785
  }
4000
4786
  clipboard.value = { mode: null, items: [], sourcePath: null };
4001
4787
  } catch (err) {
4002
- emit('error', { message: (err as Error).message, context: { op: 'paste' } });
4788
+ reportMutationError(err, { op: 'paste' });
4003
4789
  }
4004
4790
  }
4005
4791
 
@@ -4008,7 +4794,7 @@ async function duplicate(n: FileNode) {
4008
4794
  const { op } = await api.copy([n.path], qualify(currentPath.value));
4009
4795
  pendingOps.register(op);
4010
4796
  } catch (err) {
4011
- emit('error', { message: (err as Error).message, context: { op: 'duplicate' } });
4797
+ reportMutationError(err, { op: 'duplicate' });
4012
4798
  }
4013
4799
  }
4014
4800
 
@@ -4121,7 +4907,7 @@ async function onDocumentCreated(file: { path: string; name: string; ext: string
4121
4907
  previewTarget.value = node;
4122
4908
  // ⚠ NOT previewModeForExt: that sends office types to 'view', which is right
4123
4909
  // for a peek at somebody else's file and wrong for the one you just made.
4124
- previewMode.value = permCanEdit((node.perm as string) ?? dirPerm.value) ? 'edit' : 'view';
4910
+ previewMode.value = nodeCanEdit(node) ? 'edit' : 'view';
4125
4911
  showPreview.value = true;
4126
4912
  emit('file-opened', { path: node.path, basename: node.basename });
4127
4913
  void markRecent(node);
@@ -4143,6 +4929,12 @@ async function submitNewFolder(name: string) {
4143
4929
  async function submitRename(name: string) {
4144
4930
  const target = renameTarget.value;
4145
4931
  if (!target) return;
4932
+ /* ⚠ Cleared BEFORE the attempt, not only when the dialog opens. The dialog
4933
+ * drops the line as soon as the name is edited and re-shows it when this ref
4934
+ * CHANGES — so retrying the same taken name, which fails with the identical
4935
+ * sentence, left the ref untouched, nothing re-rendered, and Save looked like
4936
+ * it did nothing at all. */
4937
+ renameError.value = null;
4146
4938
  const inPane = mutationInPane.value; /* ui-fix — rename from the side pane */
4147
4939
  try {
4148
4940
  const dirWire = inPane ? qualify(splitPaneRef.value?.getPath() ?? '') : qualify(currentPath.value);
@@ -4161,7 +4953,11 @@ async function submitRename(name: string) {
4161
4953
  });
4162
4954
  }
4163
4955
  } catch (err) {
4164
- emit('error', { message: (err as Error).message, context: { op: 'rename' } });
4956
+ const e = err as Error & { status?: number };
4957
+ // 409 is the server refusing to replace what already has the name
4958
+ // (NAME_TAKEN). Everything else still says what went wrong, in the dialog.
4959
+ renameError.value = e.status === 409 ? t('newdoc.err.exists', { name }) : e.message || String(err);
4960
+ reportMutationError(err, { op: 'rename' });
4165
4961
  }
4166
4962
  }
4167
4963
 
@@ -4214,17 +5010,19 @@ async function confirmDelete() {
4214
5010
  if (inPane) void splitPaneRef.value?.reload();
4215
5011
  else selection.clear();
4216
5012
  } catch (err) {
4217
- emit('error', { message: (err as Error).message, context: { op: 'delete' } });
5013
+ reportMutationError(err, { op: 'delete' });
4218
5014
  }
4219
5015
  }
4220
5016
 
4221
5017
  function openConvert(n: FileNode) {
5018
+ // A shortcut or a stale menu must not open what the rule withholds.
5019
+ if (!legacyConvertUrl.value) return;
4222
5020
  convertTarget.value = n;
4223
5021
  showConvert.value = true;
4224
5022
  }
4225
5023
 
4226
5024
  function onConvertDone(name: string) {
4227
- flashToast(locale.value === 'en' ? `Converted → ${name}` : `Dönüştürüldü → ${name}`);
5025
+ flashToast(t('toast.converted_to', { name }));
4228
5026
  void load();
4229
5027
  }
4230
5028
 
@@ -4233,7 +5031,7 @@ function onConvertDone(name: string) {
4233
5031
 
4234
5032
  function triggerUpload() {
4235
5033
  if (!canWriteHere.value) {
4236
- flashToast(locale.value === 'en' ? 'Read-only here' : 'Burada yazma yetkiniz yok');
5034
+ flashToast(t('toast.read_only_here'));
4237
5035
  return;
4238
5036
  }
4239
5037
  fileInputEl.value?.click();
@@ -4509,7 +5307,7 @@ function onDropUpload(ev: DragEvent) {
4509
5307
  dragOver.value = false;
4510
5308
  // RBAC: block drag-drop upload where the user can't write.
4511
5309
  if (!canWriteHere.value) {
4512
- flashToast(locale.value === 'en' ? 'Read-only here' : 'Burada yazma yetkiniz yok');
5310
+ flashToast(t('toast.read_only_here'));
4513
5311
  return;
4514
5312
  }
4515
5313
  const list = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : [];
@@ -4760,7 +5558,7 @@ async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: s
4760
5558
  }
4761
5559
  selection.clear();
4762
5560
  } catch (err) {
4763
- emit('error', { message: (err as Error).message, context: { op: opLabel, targetDir } });
5561
+ reportMutationError(err, { op: opLabel, targetDir });
4764
5562
  }
4765
5563
  }
4766
5564
 
@@ -4895,6 +5693,12 @@ const { themeMode: themeModePref, setThemeMode: setThemeModePref } = useThemeMod
4895
5693
  const themeMode = computed<ThemeMode>(() =>
4896
5694
  themeModePref.value === 'host' ? props.config.theme || 'auto' : themeModePref.value,
4897
5695
  );
5696
+ /* tablo:t3 — every table under the explorer (its own listing, the connection
5697
+ * panels, an archive's or a spreadsheet's preview, an app's list) is the one
5698
+ * DataTable, and its column menu and Actions menu teleport to <body>: they
5699
+ * learn the language and the mode here, once, instead of from a prop each
5700
+ * surface would have to remember to pass (lib/tableEnv). */
5701
+ provideTableEnv({ locale, theme: themeMode });
4898
5702
  // Resolved mode: an explicit choice wins, otherwise the OS preference — the
4899
5703
  // same logic variables.css encodes in CSS. The inline root variables beat every
4900
5704
  // stylesheet rule, so they must track this resolution at runtime.
@@ -5033,23 +5837,15 @@ function retryUploadJob(job: UploadJob) {
5033
5837
  }
5034
5838
  /* /wiring:c3 */
5035
5839
  /* === wiring:c4 — onboarding coach-mark tour ===
5036
- * First mount with no `filex.tourDone` flag auto-starts the tour (short
5037
- * delay so the listing/toolbar are laid out). Closing it — finished OR
5038
- * skipped — stamps the flag; "Turu tekrar başlat" re-opens it any time.
5840
+ * Offered to a PERSON once, never once per mount (lib/tour): the first mount
5841
+ * that finds neither this browser's flag nor the account's opens it a moment
5842
+ * later and records it right away, so a second tab, a remount or another
5843
+ * device does not open it again. "Turu tekrar başlat" re-opens it any time.
5039
5844
  * Restart arrives as a bubbled `fe:tour-restart` CustomEvent from the
5040
5845
  * Toolbar overflow menu, so no extra prop/emit threading through the
5041
5846
  * shared component tags is needed. */
5042
- const TOUR_LS_KEY = 'filex.tourDone';
5043
5847
  const showTour = ref(false);
5044
- let tourTimer: ReturnType<typeof setTimeout> | undefined;
5045
-
5046
- function tourAlreadyDone(): boolean {
5047
- try {
5048
- return localStorage.getItem(TOUR_LS_KEY) === '1';
5049
- } catch {
5050
- return true; // no storage → never auto-nag
5051
- }
5052
- }
5848
+ let cancelTourOffer: (() => void) | undefined;
5053
5849
 
5054
5850
  function startTour() {
5055
5851
  showTour.value = true;
@@ -5057,11 +5853,9 @@ function startTour() {
5057
5853
 
5058
5854
  function onTourClose() {
5059
5855
  showTour.value = false;
5060
- try {
5061
- localStorage.setItem(TOUR_LS_KEY, '1');
5062
- } catch {
5063
- /* private mode / quota */
5064
- }
5856
+ // Already recorded when it was offered; recorded again for a restart that
5857
+ // somebody opened on a browser where it had never been offered.
5858
+ markTourSeen();
5065
5859
  rootEl.value?.focus();
5066
5860
  }
5067
5861
 
@@ -5071,14 +5865,12 @@ function onTourRestartEvent() {
5071
5865
 
5072
5866
  onMounted(() => {
5073
5867
  rootEl.value?.addEventListener('fe:tour-restart', onTourRestartEvent);
5074
- if (!tourAlreadyDone()) {
5075
- tourTimer = setTimeout(() => {
5076
- if (!showTour.value) startTour();
5077
- }, 900);
5078
- }
5868
+ cancelTourOffer = offerTourOnce(() => {
5869
+ if (!showTour.value) startTour();
5870
+ });
5079
5871
  });
5080
5872
  onBeforeUnmount(() => {
5081
- if (tourTimer) clearTimeout(tourTimer);
5873
+ cancelTourOffer?.();
5082
5874
  rootEl.value?.removeEventListener('fe:tour-restart', onTourRestartEvent);
5083
5875
  });
5084
5876
  /* === /wiring:c4 === */
@@ -5167,7 +5959,7 @@ function applyTabLocation(tb: TabState) {
5167
5959
  * onto one, so the tab's copy is redundant and is ignored. With it off,
5168
5960
  * nothing here changes at all and tabs keep exactly the behaviour they had.
5169
5961
  */
5170
- if (!folderMemoryEnabled() && tb.viewMode && tb.viewMode !== viewMode.value) {
5962
+ if (!folderMemoryOn.value && tb.viewMode && tb.viewMode !== viewMode.value) {
5171
5963
  viewMode.value = tb.viewMode;
5172
5964
  }
5173
5965
  /* ⚠ AFTER the load, not before. `load()` is async, so `currentPath` — and
@@ -5623,8 +6415,11 @@ async function transferItems(
5623
6415
  // "cross-storage is not supported" text here; it IS supported now, so
5624
6416
  // that text would mask the real cause (permissions, a read-only storage,
5625
6417
  // a full quota).
5626
- emit('error', { message: (err as Error).message, context: { op: 'transfer', targetWire } });
5627
- flashToast((err as Error).message);
6418
+ // ⚠ `reportMutationError` already flashed the lock sentence when the
6419
+ // server answered 423; the generic message would then overwrite it.
6420
+ const held = lockedRefusal(err);
6421
+ reportMutationError(err, { op: 'transfer', targetWire });
6422
+ if (!held) flashToast((err as Error).message);
5628
6423
  return;
5629
6424
  }
5630
6425
  } else {
@@ -6192,6 +6987,7 @@ function closeRecoveryKey() {
6192
6987
  <div
6193
6988
  ref="rootEl"
6194
6989
  class="fe"
6990
+ :dir="dir"
6195
6991
  :class="{
6196
6992
  'fe--theme-light': themeMode === 'light',
6197
6993
  'fe--theme-dark': themeMode === 'dark',
@@ -6229,7 +7025,7 @@ function closeRecoveryKey() {
6229
7025
  bar's position can never describe two different panes. */"
6230
7026
  :selection-pane="activePaneId /* pane:p1 — which half the bar mounts into */"
6231
7027
  :paste-enabled="!!clipboard.mode"
6232
- :convert-enabled="!!effectiveConvertUrl"
7028
+ :convert-enabled="!!legacyConvertUrl"
6233
7029
  :can-go-up="canGoUp"
6234
7030
  :at-virtual-root="atVirtualRoot"
6235
7031
  :can-write="canWriteHere"
@@ -6297,6 +7093,7 @@ function closeRecoveryKey() {
6297
7093
  :narrow="isNarrow"
6298
7094
  :active-view="navView"
6299
7095
  :active-tag="navTag"
7096
+ :active-tag-kind="navTagKind"
6300
7097
  :tags="navTags"
6301
7098
  :tags-loaded="navTagsLoaded"
6302
7099
  :active-storage="adapter"
@@ -6304,6 +7101,7 @@ function closeRecoveryKey() {
6304
7101
  :shared-storages="sharedStorageNames"
6305
7102
  :trash-visible="config.trashVisible !== false"
6306
7103
  :show-connections="connectionsEnabled"
7104
+ :show-my-shares="mySharesEnabled /* paylas:m1 — off unless the host has the page */"
6307
7105
  :show-identity-surfaces="identitySurfaces"
6308
7106
  :can-write="canWriteHere && !atVirtualRoot && !trashActive"
6309
7107
  :locale="locale"
@@ -6322,6 +7120,9 @@ function closeRecoveryKey() {
6322
7120
  @new-folder="showNewFolder = true"
6323
7121
  @open-connections="openConnections"
6324
7122
  @open-tokens="openTokens"
7123
+ @open-my-shares="emit('open-my-shares') /* paylas:m1 — the host owns the page */"
7124
+ :apps="pluginHomeApps /* App plugins — the home views */"
7125
+ @open-app="openPluginHome"
6325
7126
  />
6326
7127
  <!-- The drawer's scrim. A button, not a div: dismissing an overlay by
6327
7128
  clicking beside it has to be reachable from the keyboard too. -->
@@ -6406,6 +7207,7 @@ function closeRecoveryKey() {
6406
7207
  :show-view-switcher="navView !== 'home'"
6407
7208
  :folder-key="currentFolderKey"
6408
7209
  :show-parent-path="!!searchQuery || crossFolderView"
7210
+ :trash="trashActive"
6409
7211
  :clipped="clippedPaths"
6410
7212
  :extra-filters="advFilters"
6411
7213
  :can-write="canWriteHere && !trashActive"
@@ -6459,6 +7261,16 @@ function closeRecoveryKey() {
6459
7261
 
6460
7262
  <!-- Strips that describe the WINDOW's state rather than the listing. -->
6461
7263
  <template #banners>
7264
+ <!-- A search that matched more than it returned: the index filled its page,
7265
+ or the index-less fallback filled its window of names. Without this a
7266
+ cut list reads as the whole answer. No count — the number that came
7267
+ back is the window's, not the matches'. -->
7268
+ <div
7269
+ v-if="searchQuery && searchTruncated && !navView && !trashActive"
7270
+ class="fe-search-cut"
7271
+ role="status"
7272
+ >{{ t('search.truncated') }}</div>
7273
+
6462
7274
  <!-- Live presence: who else is viewing this folder (empty → nothing shown).
6463
7275
  When the live socket is unavailable the same strip carries a small
6464
7276
  degraded-connection badge instead (presence is empty in fallback);
@@ -6589,6 +7401,9 @@ function closeRecoveryKey() {
6589
7401
  view rather than of the rows. -->
6590
7402
  <div v-if="trashMode && !loading" class="fe-trashbar">
6591
7403
  <p class="fe-trashbar__text">{{ trashBannerText }}</p>
7404
+ <p v-if="trashEmptyProgress" class="fe-trashbar__progress" role="status" aria-live="polite">
7405
+ {{ trashEmptyProgress }}
7406
+ </p>
6592
7407
  <!-- Offered only when the SERVER has said this caller may purge. The
6593
7408
  backend refuses regardless of what we draw; this is so nobody is
6594
7409
  handed a button that always fails. -->
@@ -6932,6 +7747,7 @@ function closeRecoveryKey() {
6932
7747
  not what is ticked in front of you, and it lives here. */"
6933
7748
  :locale="locale"
6934
7749
  :narrow="isNarrow"
7750
+ :caller-admin="callerAdmin /* the Node ID row is an administrator's (InspectorPanel) */"
6935
7751
  :thumb-src="thumbs.src"
6936
7752
  :api-base="props.config.apiBase ?? '' /* etiket:t1 — the details panel's
6937
7753
  Tags section mounts the same TagPicker the context menu opens, and
@@ -6944,9 +7760,11 @@ function closeRecoveryKey() {
6944
7760
  API breaks without it. */"
6945
7761
  :auth-headers="() => buildAuthHeaders()"
6946
7762
  :auth-credentials="api.credentialsMode()"
6947
- @tags-changed="onNodeTagsChanged /* etiket:t1 — drop the panel's cached
6948
- tag list when the details panel edits tags, exactly as the modal
6949
- does. Harmless until InspectorPanel emits it. */"
7763
+ :plugin-views="pluginInspectorViews /* App plugins — inspector sections */"
7764
+ :theme="themeMode"
7765
+ :storages="(props.config.storages ?? []).map((st) => st.name)"
7766
+ @plugin-op="(op) => onPluginOpQueued(op)"
7767
+ @plugin-open="(p) => onSurfaceOpen(p.plugin, p.req)"
6950
7768
  @open-tag="openTagView /* etiket:t1 — a tag chip in this panel is a door to
6951
7769
  that tag's view, the same door the navigation panel's Tags section
6952
7770
  opens. */"
@@ -6980,10 +7798,8 @@ function closeRecoveryKey() {
6980
7798
  <ConnectionsPanel
6981
7799
  v-if="showConnections"
6982
7800
  :config="config"
6983
- initial-tab="connect"
6984
7801
  closable
6985
7802
  @close="closeOverlays"
6986
- @changed="() => load()"
6987
7803
  @error="onConnectionsError"
6988
7804
  />
6989
7805
  <template v-else>
@@ -7036,7 +7852,11 @@ function closeRecoveryKey() {
7036
7852
  :ops="pendingOps.ops.value"
7037
7853
  :locale="locale"
7038
7854
  :center="opsCenter"
7855
+ :output-mode-of="pluginOutputModeOf"
7856
+ :caller-admin="callerAdmin"
7039
7857
  @dismiss="(id) => pendingOps.dismiss(id)"
7858
+ @cancel="onCancelPendingOp"
7859
+ @open="onOpenOpOutput"
7040
7860
  />
7041
7861
 
7042
7862
  <OperationsCenter
@@ -7104,6 +7924,7 @@ function closeRecoveryKey() {
7104
7924
  typecheck. */"
7105
7925
  :only-office-ready="!!effectiveOnlyOfficeBase"
7106
7926
  :drawio-ready="!!effectiveDrawioUrl"
7927
+ :can-configure="callerAdmin"
7107
7928
  @close="showNewDocument = false"
7108
7929
  @created="onDocumentCreated"
7109
7930
  @error="emit('error', { message: $event.message, context: { op: 'newdoc' } })"
@@ -7172,6 +7993,7 @@ function closeRecoveryKey() {
7172
7993
  :open="showRename"
7173
7994
  :locale="locale"
7174
7995
  :current-name="renameTarget?.basename || ''"
7996
+ :error="renameError"
7175
7997
  @close="showRename = false"
7176
7998
  @submit="submitRename"
7177
7999
  />
@@ -7220,6 +8042,7 @@ function closeRecoveryKey() {
7220
8042
  :download-url="(p) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
7221
8043
  :only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 — OO cannot open ciphertext */"
7222
8044
  :only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
8045
+ :can-configure="callerAdmin"
7223
8046
  :new-tab-enabled="!e2eActive /* wiring:e2 — the standalone route pulls raw bytes */"
7224
8047
  :save-text-endpoint="e2eActive ? null : api.endpoints.saveText || null /* wiring:e2 — a plaintext save would be a leak */"
7225
8048
  :archive-list-endpoint="api.endpoints.archiveList || null"
@@ -7245,9 +8068,39 @@ function closeRecoveryKey() {
7245
8068
  @nav="onPreviewNav"
7246
8069
  @close="showPreview = false"
7247
8070
  />
8071
+ <!-- App plugins — a `modal` view, and the manifest's confirm question. -->
8072
+ <PluginViewModal
8073
+ v-if="pluginView"
8074
+ :open="!!pluginView"
8075
+ :api="api"
8076
+ :locale="locale"
8077
+ :theme="themeMode"
8078
+ :plugin="pluginView.plugin"
8079
+ :view="pluginView.view"
8080
+ :surface="pluginView.surface"
8081
+ :path="pluginView.path"
8082
+ :size="pluginView.size"
8083
+ :storages="(props.config.storages ?? []).map((st) => st.name)"
8084
+ :start-at="qualify(paneIsActive ? (splitPaneRef?.getPath() ?? '') : currentPath)"
8085
+ @close="pluginView = null"
8086
+ @op="(op) => onPluginOpQueued(op)"
8087
+ @toast="flashToast"
8088
+ @open="(req) => onSurfaceOpen(pluginView?.plugin ?? '', req)"
8089
+ />
8090
+ <PluginConfirmModal
8091
+ :open="!!pluginConfirm"
8092
+ :locale="locale"
8093
+ :theme="themeMode"
8094
+ :title="pluginConfirm ? pluginLabelOf(pluginConfirm.action.label, locale) : ''"
8095
+ :message="pluginConfirm ? pluginLabelOf(pluginConfirm.action.confirm, locale) : ''"
8096
+ :danger="pluginConfirm?.action.danger === true"
8097
+ @close="pluginConfirm = null"
8098
+ @confirm="onPluginConfirmed"
8099
+ />
7248
8100
  <ConvertModal
7249
- v-if="showConvert && convertTarget && effectiveConvertUrl"
7250
- :convert-url="effectiveConvertUrl"
8101
+ v-if="showConvert && convertTarget && legacyConvertUrl"
8102
+ :convert-url="legacyConvertUrl"
8103
+ :admin-note="callerAdmin ? t('convert.legacy_admin') : ''"
7251
8104
  :file-name="convertTarget?.basename || convertTarget?.path || ''"
7252
8105
  :fetch-bytes="() => api.fetchArrayBuffer(convertTarget?.path ?? '')"
7253
8106
  :upload="(f) => api.uploadMultipart(qualify(currentPath), [f]).then(() => {})"
@@ -7261,9 +8114,12 @@ function closeRecoveryKey() {
7261
8114
  :path="permTarget.path"
7262
8115
  :is-dir="permTarget.type === 'dir'"
7263
8116
  :size="typeof permTarget.size === 'number' ? permTarget.size : undefined"
7264
- :locale="locale === 'en' ? 'en' : 'tr'"
8117
+ :locale="locale"
7265
8118
  :share-max-ttl-days="shareMaxTtlDays"
7266
8119
  :initial-tab="permInitialTab /* surucu:d1 */"
8120
+ :mail-ready="capabilitiesData?.mail?.ready"
8121
+ :can-configure="callerAdmin"
8122
+ :perm="permTarget.perm || dirPerm"
7267
8123
  @close="showPerm = false; permInitialTab = undefined"
7268
8124
  />
7269
8125
 
@@ -7284,16 +8140,18 @@ function closeRecoveryKey() {
7284
8140
  >
7285
8141
  <div class="fe-recents__panel" @click.stop>
7286
8142
  <div class="fe-recents__header">
7287
- <strong>{{ locale === 'en' ? 'Recently opened' : 'Son açılanlar' }}</strong>
7288
- <button class="fe-recents__close" aria-label="Close" @click="showRecents = false">×</button>
8143
+ <strong>{{ t('recents.title') }}</strong>
8144
+ <button class="fe-recents__close" :aria-label="t('recents.close')" @click="showRecents = false">×</button>
7289
8145
  </div>
7290
8146
  <RecentlyOpened
8147
+ :locale="locale"
7291
8148
  :api-base="props.config.apiBase ?? ''"
7292
8149
  :auth-headers="() => buildAuthHeaders()"
7293
8150
  :auth-credentials="api.credentialsMode()"
7294
8151
  :limit="20"
7295
8152
  :refresh-key="recentRefreshKey"
7296
8153
  @open="onRecentOpen"
8154
+ @context="onRecentContext"
7297
8155
  @error="(msg: string) => emit('error', { message: msg, context: { op: 'recents' } })"
7298
8156
  />
7299
8157
  </div>
@@ -7310,9 +8168,9 @@ function closeRecoveryKey() {
7310
8168
  <div class="fe-modal__card fe-modal__card--md" @click.stop>
7311
8169
  <header class="fe-modal__head">
7312
8170
  <h2 class="fe-modal__title">
7313
- {{ locale === 'en' ? 'Tags' : 'Etiketler' }} — {{ tagPickerNode.basename }}
8171
+ {{ t('tags.title') }} — {{ tagPickerNode.basename }}
7314
8172
  </h2>
7315
- <button class="fe-modal__close" aria-label="Close" @click="showTagPicker = false">×</button>
8173
+ <button class="fe-modal__close" :aria-label="t('tags.close')" @click="showTagPicker = false">×</button>
7316
8174
  </header>
7317
8175
  <div class="fe-modal__body">
7318
8176
  <TagPicker
@@ -7321,7 +8179,6 @@ function closeRecoveryKey() {
7321
8179
  :api-base="props.config.apiBase ?? ''"
7322
8180
  :auth-headers="() => buildAuthHeaders()"
7323
8181
  :auth-credentials="api.credentialsMode()"
7324
- @change="onNodeTagsChanged"
7325
8182
  @open="openTagView /* etiket:t1 — the SAME door as in the details
7326
8183
  panel; `openTagView` closes this dialog on the way, because
7327
8184
  leaving a modal open over the view it just navigated to is
@@ -7345,6 +8202,7 @@ function closeRecoveryKey() {
7345
8202
  :path-base="advPathBase"
7346
8203
  :content-search="advContentAvailable"
7347
8204
  :count="advSearchCount"
8205
+ :known-tags="advKnownTags"
7348
8206
  @close="showAdvSearch = false"
7349
8207
  @submit="applyAdvancedSearch"
7350
8208
  />
@@ -7449,6 +8307,7 @@ function closeRecoveryKey() {
7449
8307
  :download-url="(p: string) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
7450
8308
  :only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 */"
7451
8309
  :only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
8310
+ :can-configure="callerAdmin"
7452
8311
  :auth-headers="() => buildAuthHeaders({ 'Content-Type': 'application/json' })"
7453
8312
  :auth-credentials="api.credentialsMode()"
7454
8313
  :drawio-url="effectiveDrawioUrl"
@@ -7473,3 +8332,8 @@ function closeRecoveryKey() {
7473
8332
 
7474
8333
  <style src="./styles/variables.css"></style>
7475
8334
  <style src="./styles/base.css"></style>
8335
+ <!-- The five signing faces, self-hosted. Declared here so the ONE rolled-up
8336
+ `style.css` every host already imports carries them; a browser fetches a
8337
+ `.woff2` only when text is actually drawn in that family, so a host that
8338
+ never opens a signing screen pays nothing. -->
8339
+ <style src="./styles/sign-fonts.css"></style>