@brftech/filex-core 0.42.2 → 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 (234) 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-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-CAoTSXTO.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-CdZeK4j4.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 +5227 -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 +1055 -253
  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 +31 -15
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -62
  76. package/src/components/SSHKeysPanel.vue +79 -30
  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 -31
  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 +18 -3
  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 +515 -117
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +536 -152
  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 +2890 -260
  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 +50 -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-BUQWLhwX.js +0 -124
  216. package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
  217. package/dist/CsvViewer-Bg2_aqzc.js +0 -141
  218. package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
  219. package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
  220. package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
  221. package/dist/IpynbViewer-D0GwGpnE.js +0 -184
  222. package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
  223. package/dist/MermaidViewer-DGwAvRZg.js +0 -137
  224. package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
  225. package/dist/PsdViewer-DhGweA0m.js +0 -122
  226. package/dist/PsdViewer-DhGweA0m.js.map +0 -1
  227. package/dist/TiffViewer-D0AurvAh.js +0 -142
  228. package/dist/TiffViewer-D0AurvAh.js.map +0 -1
  229. package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
  230. package/dist/index-BArI-B1M.js +0 -26911
  231. package/dist/index-BArI-B1M.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
  234. package/src/composables/useScrolledX.ts +0 -21
@@ -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,6 +165,7 @@ import {
156
165
  isVirtualViewPath,
157
166
  makeTagSegment,
158
167
  tagOfPath,
168
+ tagKindOfPath,
159
169
  showHiddenFiles,
160
170
  setShowHiddenFiles,
161
171
  injectTrashRow,
@@ -164,8 +174,16 @@ import {
164
174
  import { nodeRowToFileNode as nodeRowToFileNodePure } from './lib/nodeRow'; /* Recent / Starred / tag / Home rows — one shape, with `perm` + `read_only` */
165
175
  import { iconFamilyFor, isStorageRow } from './lib/fileIcons'; /* pane:p1 — the storage-row predicate's one home */
166
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';
167
184
  import { setNodeStarred } from './lib/star';
168
- 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';
169
187
  import { resolveTransfer, type TransferIntent } from './lib/transfer';
170
188
  import { downloadArchive } from './lib/downloadSelection'; /* tasi:m1 */
171
189
  import { labelOfWire } from './lib/destinationTree'; /* tasi:m1 */
@@ -189,6 +207,8 @@ import DeleteConfirmModal from './modals/DeleteConfirmModal.vue';
189
207
  import Modal from './modals/Modal.vue'; /* tablo:t1 — the empty-trash confirmation */
190
208
  import PreviewModal from './modals/PreviewModal.vue';
191
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';
192
212
  import PermissionsModal from './modals/PermissionsModal.vue';
193
213
  import DestinationPickerModal from './modals/DestinationPickerModal.vue'; /* tasi:m1 */
194
214
  import { resolveLocale } from './locales/resolve';
@@ -227,6 +247,29 @@ const emit = defineEmits<{
227
247
  * storage list simply ignores it.
228
248
  */
229
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;
230
273
  }>();
231
274
 
232
275
  // --------------------------------------------------------------------
@@ -249,7 +292,9 @@ const locale = computed(() => resolveLocale(props.config.locale));
249
292
  * either would leave the comparator on a stale alphabet exactly when they are
250
293
  * not mounted. */
251
294
  watch(locale, (l) => setSortLocale(l), { immediate: true });
252
- 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).
253
298
 
254
299
  // Live collaboration (WebSocket file-change events + presence), bundled into the
255
300
  // core so every consumer — the native panel AND the embedded webcomponent —
@@ -299,10 +344,21 @@ const pendingOps = usePendingOps(props.config, api, {
299
344
  onSettled: (op: PendingOp) => {
300
345
  const undo = opUndo.get(op.id);
301
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
+ }
302
353
  if (op.status === 'error') {
303
- 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);
304
357
  } else if (undo) {
305
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) }));
306
362
  } else {
307
363
  const verb =
308
364
  op.op_type === 'copy'
@@ -352,23 +408,27 @@ function retryLoad() {
352
408
  void load(loadErrorPath);
353
409
  }
354
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
+ */
355
416
  const VIEW_MODE_KEY = 'brf-file-explorer:view-mode';
356
417
  /**
357
- * tablo:t1 — true while a folder's REMEMBERED setup is being restored, as
358
- * opposed to a person choosing one.
418
+ * The view mode on screen.
359
419
  *
360
- * ⚠⚠ Without this the whole design inverts. `brf-file-explorer:view-mode` is
361
- * the GLOBAL default — what a folder nobody has configured opens as — and the
362
- * per-folder memory is layered over it. If restoring a folder's grid view also
363
- * wrote the global key, then merely WALKING INTO one folder you once set to
364
- * grid would make grid the default for the entire product, and the person
365
- * would have no way to tell which folder did it. Restoring is not choosing.
366
- * The same flag also keeps the watcher below from re-recording what it just
367
- * 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.
368
429
  */
369
- let restoringFolderView = false;
370
- const viewMode = customRef<ViewMode>((track, trigger) => {
371
- let value: ViewMode = (() => {
430
+ const viewMode = ref<ViewMode>(
431
+ (() => {
372
432
  try {
373
433
  const stored = localStorage.getItem(VIEW_MODE_KEY);
374
434
  if (stored === 'list' || stored === 'grid' || stored === 'gallery') return stored; /* wiring:d2 */
@@ -376,30 +436,20 @@ const viewMode = customRef<ViewMode>((track, trigger) => {
376
436
  /* private mode */
377
437
  }
378
438
  return props.config.viewMode ?? 'list';
379
- })();
380
- return {
381
- get() {
382
- track();
383
- return value;
384
- },
385
- set(next) {
386
- if (next === value) return;
387
- value = next;
388
- if (!restoringFolderView) {
389
- try {
390
- localStorage.setItem(VIEW_MODE_KEY, next);
391
- } catch {
392
- /* quota */
393
- }
394
- }
395
- trigger();
396
- },
397
- };
398
- });
439
+ })(),
440
+ );
399
441
  /* cila:a density — Toolbar owns the persisted preference (filex.density);
400
442
  mirrored here only so the root `.fe` can carry fe--density-compact. */
401
443
  const density = ref<'comfortable' | 'compact'>('comfortable');
402
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;
403
453
  // trashMode — true while viewing the filex trash (soft-deleted nodes from the
404
454
  // backend trash endpoint), entered by opening the virtual `.trash` row and
405
455
  // exited by any normal navigation (load() resets it). Replaces a brittle
@@ -466,66 +516,82 @@ const folderMemoryOn = computed(() => props.config.rememberFolderView !== false)
466
516
  const currentFolderKey = computed(() => {
467
517
  if (!folderMemoryOn.value) return '';
468
518
  const path = String(currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
469
- 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;
470
523
  const [first, ...rest] = path.split('/');
471
524
  const st = (props.config.storages ?? []).find((s) => s.name === first);
472
525
  return makeFolderKey(st?.uid || first, rest.join('/'));
473
526
  });
474
527
 
475
- /** The GLOBAL default view mode — what a folder nobody has configured opens
476
- * as. Read from the same key the `viewMode` ref persists to, so there is one
477
- * answer rather than a second copy drifting beside it. */
478
- function globalViewMode(): ViewMode {
479
- try {
480
- const stored = localStorage.getItem(VIEW_MODE_KEY);
481
- if (stored === 'list' || stored === 'grid' || stored === 'gallery') return stored;
482
- } catch {
483
- /* 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
+ }
484
544
  }
485
545
  return props.config.viewMode ?? 'list';
486
546
  }
487
547
 
488
- /** What we last RESTORED, so the recorder below can tell a restore's echo from
489
- * 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.
490
553
  *
491
- * ⚠⚠ A signature and not a boolean flag, and the difference is a bug I would
492
- * otherwise have shipped: Vue's watchers are asynchronous, so a `restoring =
493
- * true … restoring = false` fence around the assignment is already back down
494
- * by the time the recorder runs and every navigation would record itself as a
495
- * deliberate choice — which would mean walking through a folder configures it.
496
- * The `viewMode` setter's own guard CAN be a flag because a `customRef` setter
497
- * runs synchronously inside the fence; these two need different mechanisms
498
- * because they run at different times. */
499
- 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
+ );
500
563
 
501
- function viewSig(key: string): string {
502
- return `${key}|${viewMode.value}|${activeSortKey()}|${activeSortDir()}`;
503
- }
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;
504
576
 
505
- /** 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. */
506
579
  function applyFolderView(key: string) {
507
- if (!key) return;
580
+ if (!key && folderMemoryOn.value) return;
508
581
  touchFolder(key); // LRU clock — only bumps folders already remembered
509
- const p = folderPrefs(key);
510
- restoringFolderView = true;
511
- try {
512
- /* ⚠ The `else` halves matter as much as the `if`s. Without them a folder
513
- * with no memory of its own would inherit whatever the PREVIOUS folder was
514
- * restored to — walk from a remembered gallery into a plain folder and it
515
- * comes up as a gallery, which reads as the memory leaking rather than as
516
- * a default holding. A folder with no memory follows the global default,
517
- * and that has to be asserted, not assumed. */
518
- const wantView = p?.v ?? globalViewMode();
519
- if (wantView !== viewMode.value) viewMode.value = wantView;
520
- if (p?.k) applySort(p.k, p.d ?? defaultSortDir(p.k));
521
- else {
522
- const g = globalSort();
523
- applySort(g.key, g.dir);
524
- }
525
- } finally {
526
- 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);
527
593
  }
528
- appliedSig = viewSig(key);
594
+ applied = { key, v: viewMode.value, k: activeSortKey(), d: activeSortDir() };
529
595
  }
530
596
 
531
597
  /**
@@ -538,45 +604,13 @@ function applyFolderView(key: string) {
538
604
  * land first in practice, and nothing is applied to a folder until they do.
539
605
  */
540
606
  onMounted(() => {
541
- const base = props.config.apiBase ?? '';
542
- const url = `${base}/api/files/manager/view-prefs`;
543
- attachViewPrefsStore({
544
- async load() {
545
- const res = await fetch(url, {
546
- headers: await buildAuthHeaders(),
547
- credentials: api.credentialsMode(),
548
- });
549
- /* ⚠ A 401 is not an error here, it is an ANSWER: an app token or a
550
- * public share link has no person to remember anything for. Returning
551
- * null degrades to "remember nothing, write nothing", which is what the
552
- * module does with it — no retry loop, no console noise. */
553
- if (!res.ok) return null;
554
- const body = (await res.json()) as { prefs?: unknown };
555
- return body?.prefs ?? null;
556
- },
557
- save(doc) {
558
- void (async () => {
559
- try {
560
- const payload = JSON.stringify({ prefs: doc });
561
- await fetch(url, {
562
- method: 'PUT',
563
- headers: { ...(await buildAuthHeaders()), 'Content-Type': 'application/json' },
564
- credentials: api.credentialsMode(),
565
- body: payload,
566
- /* ⚠ `keepalive` is what lets the save fired on `pagehide` outlive
567
- * the page — but browsers cap a keepalive body at 64 KB and reject
568
- * the request outright above it. The document is capped far below
569
- * that (300 folders ≈ 33 KB), so this only ever guards the
570
- * pathological case; sending it without keepalive is strictly
571
- * better than having it rejected. */
572
- keepalive: payload.length < 60000,
573
- });
574
- } catch {
575
- /* Fire and forget. A view preference is never worth a toast, and the
576
- next save carries the whole document again. */
577
- }
578
- })();
579
- },
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(),
580
614
  });
581
615
  });
582
616
 
@@ -600,35 +634,58 @@ watch(
600
634
  { immediate: true, flush: 'post' },
601
635
  );
602
636
 
603
- /** A person changed a view. Record it against the folder — and note that the
604
- * GLOBAL default was already written by the control they used (`setSort`
605
- * persists; the `viewMode` setter persists), which is what makes "Apply to
606
- * 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
+ */
607
651
  watch(
608
652
  () => [currentFolderKey.value, viewMode.value, activeSortKey(), activeSortDir()] as const,
609
653
  ([key, v, k, d]) => {
610
- /* ⚠ Nothing is recorded before the document has landed. Until then the
611
- * state on screen is this session's defaults, not the person's choices,
612
- * and writing it back would overwrite everything they had arranged with
613
- * whatever the app happened to boot into. */
614
- if (!key || !viewPrefsReady()) return;
615
- const sig = viewSig(key);
616
- if (sig === appliedSig) return; // the restore, echoing back
617
- appliedSig = sig;
618
- 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);
619
668
  },
620
- /* ⚠⚠ `post`, and registered AFTER the applier, for a reason that is not
621
- * stylistic. A navigation changes `currentFolderKey`, which is a dependency
622
- * of BOTH watchers. Left pre-flush this one would run first, while the view
623
- * state is still the folder you just LEFT, and write that folder's view mode
624
- * and sort against the folder you just arrived in — every walk through the
625
- * tree quietly configuring the next folder with the last one's setup. Post
626
- * puts the applier first; by the time this runs, `appliedSig` matches and it
627
- * 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. */
628
673
  { flush: 'post' },
629
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
+ );
630
684
  /** The tag being browsed while navView === 'tag' ('' otherwise). */
631
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 | ''>('');
632
689
  /** Sentinel parked in `dirname` so the breadcrumb can label the view. The tag
633
690
  * view's sentinel is built per tag (`makeTagSegment`) — see lib/listing. */
634
691
  const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> = {
@@ -645,7 +702,9 @@ const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> =
645
702
  * for a folder called `.starred` and landing on "not found". (That was already
646
703
  * true of the four shipped views; the tag view would have inherited it.)
647
704
  */
648
- 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 {
649
708
  /* ⚠ The "is this a sentinel at all?" half is `lib/listing`'s
650
709
  `isVirtualViewPath`, not a second reading of the map here: FilePane has to
651
710
  answer the same question before it qualifies a path (a qualified sentinel
@@ -655,9 +714,9 @@ function virtualViewOf(path: string): { kind: Exclude<NavView, ''>; tag: string
655
714
  if (!isVirtualViewPath(path)) return null;
656
715
  const clean = String(path ?? '').replace(/^\/+|\/+$/g, '');
657
716
  const tag = tagOfPath(clean);
658
- if (tag) return { kind: 'tag', tag };
717
+ if (tag) return { kind: 'tag', tag, tagKind: tagKindOfPath(clean) };
659
718
  const kind = clean.slice(1) as Exclude<NavView, '' | 'tag'>;
660
- return { kind, tag: '' };
719
+ return { kind, tag: '', tagKind: '' };
661
720
  }
662
721
 
663
722
  // When the caller can see exactly ONE storage, the multi-storage root is a
@@ -811,6 +870,62 @@ watch(
811
870
  const clipboard = ref<ClipboardState>({ mode: null, items: [], sourcePath: null });
812
871
 
813
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
+
814
929
  /* zaman:z3 — the two clock tiers only an explorer instance can know: the zone
815
930
  * its host configured, and the account behind its credential when that
816
931
  * credential is a person's. Ranked in lib/timezone, never here. */
@@ -824,6 +939,385 @@ useExplorerTimeZone({
824
939
  // ceiling). Both share dialogs derive their expiry choices from it.
825
940
  const shareMaxTtlDays = computed(() => capabilitiesData.value?.share_max_ttl_days ?? 0);
826
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
+
827
1321
  // Creative UI state: starred / tags / recently-opened. The component
828
1322
  // helpers (StarButton, TagPicker, RecentlyOpened) handle their own
829
1323
  // API calls — the explorer just tracks the cross-row state needed to
@@ -988,16 +1482,24 @@ function openTagPickerFor(n: FileNode) {
988
1482
  * once the chip has navigated, leaving it up means a dialog about one file
989
1483
  * covering the view of all the others that share its tag.
990
1484
  */
991
- function openTagView(tag: string) {
1485
+ function openTagView(tag: string, kind: TagKind | '' = '') {
992
1486
  showTagPicker.value = false;
993
- void loadTagView(tag);
1487
+ void loadTagView(tag, kind);
994
1488
  }
995
1489
 
996
1490
  function onNodeTagsChanged() {
997
- invalidateTagCache();
998
- void loadNavTags(true);
999
- 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);
1000
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);
1001
1503
 
1002
1504
  function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
1003
1505
  // RecentlyOpened emits the bare row. The same converter Recent / Starred /
@@ -1049,6 +1551,16 @@ const effectiveOnlyOfficeBase = computed<string | null>(() => {
1049
1551
  return capabilitiesData.value?.onlyoffice_url || null;
1050
1552
  });
1051
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
+
1052
1564
  const effectiveOnlyOfficeConfigEndpoint = computed<string | null>(() => {
1053
1565
  if (!effectiveOnlyOfficeBase.value) return null;
1054
1566
  return api.endpoints.onlyOfficeConfig || null;
@@ -1065,8 +1577,35 @@ const effectiveDrawioUrl = computed<string | null>(() => {
1065
1577
  // Universal converter (p2r3/convert fork). convert_url is only populated by
1066
1578
  // the backend when the "convert" external service is enabled, so a simple
1067
1579
  // presence check is enough gating.
1068
- const effectiveConvertUrl = computed<string | null>(
1069
- () => 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,
1070
1609
  );
1071
1610
 
1072
1611
  /* belge:n1 — what the SERVER can create, crossed with what WE could open.
@@ -1095,6 +1634,14 @@ const showRename = ref(false);
1095
1634
  const showDelete = ref(false);
1096
1635
  const showPreview = ref(false);
1097
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
+ });
1098
1645
  /* ui-fix — does the open rename/delete/new-folder modal belong to the side
1099
1646
  * pane? (the menu is identical to the main pane's; this routes the mutation
1100
1647
  * to the right one.) */
@@ -1285,12 +1832,23 @@ const sharedStorageNames = ref<string[]>([]);
1285
1832
  * embedder's users had no path to a protocol guide or to the API token those
1286
1833
  * guides tell them to use.
1287
1834
  *
1288
- * ⚠ Not gated on role. The backend decides — ConnectionsPanel renders what the
1289
- * API returns (a non-admin gets the guides and a "why not" card instead of the
1290
- * 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.
1291
1838
  */
1292
1839
  const connectionsEnabled = computed(() => props.config.connections ?? !simpleUi.value);
1293
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
+
1294
1852
  /**
1295
1853
  * Is this caller an integration rather than a person (backend migration 00030)?
1296
1854
  *
@@ -1474,6 +2032,19 @@ watch(
1474
2032
  * 2. **The live count**, which is a real query. See `advSearchCount`.
1475
2033
  */
1476
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
+ });
1477
2048
  /** The folder the dialog was opened from, frozen for its lifetime. */
1478
2049
  const advPathBase = ref('');
1479
2050
 
@@ -1536,15 +2107,28 @@ function advHitToNode(h: GlobalSearchHit, storageName: string): FileNode {
1536
2107
  };
1537
2108
  }
1538
2109
 
1539
- /** Run one advanced search and hand back the rows, unfiltered. */
1540
- 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 }> {
1541
2119
  if (scope === 'name') {
1542
2120
  const resp = await api.search(target, query);
1543
- return filterListing(resp.files);
2121
+ return {
2122
+ rows: filterListing(resp.files),
2123
+ truncated: advSearchTruncated(resp.files.length, MANAGER_SEARCH_PAGE, resp.truncated),
2124
+ };
1544
2125
  }
1545
2126
  const hits = await api.globalSearch(query, { limit: ADV_CONTENT_LIMIT, scope });
1546
2127
  const storageName = adapter.value || (props.config.storages ?? [])[0]?.name || '';
1547
- 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
+ };
1548
2132
  }
1549
2133
 
1550
2134
  /** The target `load()` would use for the current position. */
@@ -1567,11 +2151,10 @@ function advTarget(): string {
1567
2151
  */
1568
2152
  async function advSearchCount(req: AdvSearchRequest): Promise<AdvCountResult> {
1569
2153
  const query = advQueryString(req);
1570
- const rows = await advFetchRows(req.scope, query, advTarget());
1571
- const limit = req.scope === 'name' ? 250 : ADV_CONTENT_LIMIT;
2154
+ const { rows, truncated } = await advFetchRows(req.scope, query, advTarget());
1572
2155
  return {
1573
2156
  count: applyFilters(rows, req.filters).length,
1574
- capped: advSearchTruncated(rows.length, limit),
2157
+ capped: truncated,
1575
2158
  };
1576
2159
  }
1577
2160
 
@@ -1811,6 +2394,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1811
2394
  if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
1812
2395
  navView.value = 'home';
1813
2396
  navTag.value = '';
2397
+ navTagKind.value = '';
1814
2398
  trashMode.value = false;
1815
2399
  e2eRoot.value = '';
1816
2400
  forgetFolderPerm();
@@ -1824,7 +2408,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1824
2408
  }
1825
2409
  if (kind === 'tag') {
1826
2410
  // The tag view needs a name; the panel calls loadTagView directly.
1827
- if (navTag.value) await loadTagView(navTag.value);
2411
+ if (navTag.value) await loadTagView(navTag.value, navTagKind.value);
1828
2412
  return;
1829
2413
  }
1830
2414
  if (kind === 'trash') {
@@ -1836,6 +2420,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1836
2420
  // never lights up.
1837
2421
  navView.value = 'trash';
1838
2422
  navTag.value = '';
2423
+ navTagKind.value = '';
1839
2424
  return;
1840
2425
  }
1841
2426
  loading.value = true;
@@ -1845,6 +2430,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1845
2430
  if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
1846
2431
  navView.value = kind;
1847
2432
  navTag.value = '';
2433
+ navTagKind.value = '';
1848
2434
  trashMode.value = false;
1849
2435
  e2eRoot.value = '';
1850
2436
  forgetFolderPerm();
@@ -1878,7 +2464,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1878
2464
  * hash — goes through `virtualSegmentLabel`, so none of them can print the
1879
2465
  * sentinel the way the strip once printed `.shared`.
1880
2466
  */
1881
- async function loadTagView(tag: string) {
2467
+ async function loadTagView(tag: string, kind: TagKind | '' = '') {
1882
2468
  closeNavDrawer();
1883
2469
  const name = String(tag ?? '').trim();
1884
2470
  if (!name) return;
@@ -1886,18 +2472,26 @@ async function loadTagView(tag: string) {
1886
2472
  if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
1887
2473
  navView.value = 'tag';
1888
2474
  navTag.value = name;
2475
+ navTagKind.value = kind;
1889
2476
  trashMode.value = false;
1890
2477
  e2eRoot.value = '';
1891
2478
  forgetFolderPerm();
1892
2479
  selection.clear();
1893
2480
  try {
1894
- const rows = await fetchTaggedRows(name, {
1895
- apiBase: props.config.apiBase ?? '',
1896
- authHeaders: () => buildAuthHeaders(),
1897
- authCredentials: api.credentialsMode(),
1898
- });
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
+ );
1899
2491
  files.value = rows.map(nodeRowToFileNode).filter((n): n is FileNode => n !== null);
1900
- 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);
1901
2495
  dirname.value = seg;
1902
2496
  currentPath.value = seg;
1903
2497
  // Spans every storage, like Starred/Recent/Shared — so no storage crumb.
@@ -1905,7 +2499,7 @@ async function loadTagView(tag: string) {
1905
2499
  } catch (err) {
1906
2500
  const msg = err instanceof Error ? err.message : String(err);
1907
2501
  files.value = [];
1908
- emit('error', { message: msg, context: { op: `nav-view:tag:${name}` } });
2502
+ emit('error', { message: msg, context: { op: `nav-view:tag:${kind ? `${kind}:` : ''}${name}` } });
1909
2503
  flashToast(msg);
1910
2504
  } finally {
1911
2505
  loading.value = false;
@@ -1924,7 +2518,7 @@ async function loadTagView(tag: string) {
1924
2518
  * none. The cache is dropped the instant the user edits tags, which is the
1925
2519
  * only staleness anybody can notice.
1926
2520
  */
1927
- const navTags = ref<string[]>([]);
2521
+ const navTags = ref<TagItem[]>([]);
1928
2522
  const navTagsLoaded = ref(false);
1929
2523
 
1930
2524
  async function loadNavTags(force = false) {
@@ -2291,6 +2885,9 @@ function refreshAll() {
2291
2885
  }
2292
2886
 
2293
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;
2294
2891
  /* === etiket:t1 — a sentinel is a VIEW, not a folder ===================
2295
2892
  * A restored tab, a reload on `#.trash` / `#.starred` / `#.tag~invoices`,
2296
2893
  * or the breadcrumb crumb for the view you are standing in all arrive here
@@ -2329,7 +2926,7 @@ async function load(path?: string) {
2329
2926
  writePersistedPath('');
2330
2927
  return await load('');
2331
2928
  }
2332
- if (asView.kind === 'tag') await loadTagView(asView.tag);
2929
+ if (asView.kind === 'tag') await loadTagView(asView.tag, asView.tagKind);
2333
2930
  else await loadNavView(asView.kind);
2334
2931
  return;
2335
2932
  }
@@ -2365,6 +2962,7 @@ async function load(path?: string) {
2365
2962
  folder listing. */
2366
2963
  navView.value = '';
2367
2964
  navTag.value = '';
2965
+ navTagKind.value = '';
2368
2966
  let requested = path ?? currentPath.value ?? '';
2369
2967
  try {
2370
2968
  notFoundPath.value = '';
@@ -2403,17 +3001,24 @@ async function load(path?: string) {
2403
3001
  here. Everything downstream — the views, the selection, the inspector —
2404
3002
  sees ordinary rows, which is the point: one results surface. */
2405
3003
  const advContent = !!searchQuery.value && advScope.value !== 'name';
2406
- const resp: ManagerResponse = advContent
3004
+ const adv = advContent ? await advFetchRows(advScope.value, searchQuery.value, target) : null;
3005
+ const resp: ManagerResponse = adv
2407
3006
  ? {
2408
3007
  adapter: adapter.value,
2409
3008
  storages: (props.config.storages ?? []).map((s) => s.name),
2410
3009
  dirname: dirname.value,
2411
3010
  read_only: false,
2412
- files: await advFetchRows(advScope.value, searchQuery.value, target),
3011
+ files: adv.rows,
3012
+ truncated: adv.truncated,
2413
3013
  }
2414
3014
  : searchQuery.value
2415
3015
  ? await api.search(target, searchQuery.value)
2416
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);
2417
3022
  adapter.value = resp.adapter;
2418
3023
  dirname.value = resp.dirname;
2419
3024
  dirPerm.value = (resp.perm as string) || '';
@@ -2523,6 +3128,14 @@ const trashRetentionDays = ref<number | null>(null);
2523
3128
  const trashCanEmpty = ref(false);
2524
3129
  const trashEmptying = ref(false);
2525
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
+ });
2526
3139
 
2527
3140
  async function probeTrashPolicy() {
2528
3141
  trashRetentionDays.value = null;
@@ -2571,24 +3184,73 @@ const trashConfirmKey = computed(() =>
2571
3184
  trashSizeKnown.value ? 'trash.empty_confirm_body' : 'trash.empty_confirm_body_nosize',
2572
3185
  );
2573
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. */
2574
3201
  async function emptyTrash() {
2575
3202
  showTrashConfirm.value = false;
2576
3203
  if (!trashCanEmpty.value || trashEmptying.value) return;
2577
3204
  trashEmptying.value = true;
3205
+ const url = `${props.config.apiBase ?? ''}/api/admin/trash/empty`;
2578
3206
  try {
2579
- const res = await fetch(`${props.config.apiBase ?? ''}/api/admin/trash/empty`, {
2580
- method: 'POST',
2581
- headers: await buildAuthHeaders(),
2582
- 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,
2583
3229
  });
2584
- if (!res.ok) throw new Error(String(res.status));
2585
- await loadTrash();
2586
- 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'));
2587
3244
  } catch (err) {
3245
+ if (err instanceof TrashEmptyBusy) {
3246
+ flashToast(t('trash.empty_busy'));
3247
+ return;
3248
+ }
2588
3249
  const msg = err instanceof Error ? err.message : String(err);
2589
3250
  emit('error', { message: msg, context: { op: 'trash:empty' } });
2590
3251
  flashToast(msg);
2591
3252
  } finally {
3253
+ trashEmptyRun.value = null;
2592
3254
  trashEmptying.value = false;
2593
3255
  }
2594
3256
  }
@@ -2606,7 +3268,10 @@ async function loadTrash() {
2606
3268
  ({
2607
3269
  type: 'file',
2608
3270
  id: e.id,
2609
- 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,
2610
3275
  basename: e.name,
2611
3276
  extension: e.name.includes('.') ? e.name.split('.').pop() || '' : '',
2612
3277
  storage: e.storage_name || '',
@@ -2619,6 +3284,10 @@ async function loadTrash() {
2619
3284
  size: e.size,
2620
3285
  file_size: e.size,
2621
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,
2622
3291
  extra_metadata: { deleted_at: e.deleted_at, ttl_days: e.ttl_days ?? null },
2623
3292
  }) as unknown as FileNode,
2624
3293
  );
@@ -2858,7 +3527,12 @@ watch(currentPath, (p) => {
2858
3527
  // Let a host force a soft re-fetch of the current folder (reusing the existing
2859
3528
  // list-fetch) — used by the realtime layer to refresh on live change events
2860
3529
  // without a full component remount.
2861
- 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
+ });
2862
3536
 
2863
3537
  onMounted(async () => {
2864
3538
  // Eagerly start fetching Monaco — the user doesn't pay for it
@@ -2904,16 +3578,10 @@ onMounted(async () => {
2904
3578
  if (api.endpoints.opsList) {
2905
3579
  pendingOps.startPolling();
2906
3580
  }
2907
- if (api.endpoints.capabilities) {
2908
- api
2909
- .capabilities()
2910
- .then((cap) => {
2911
- capabilitiesData.value = cap;
2912
- })
2913
- .catch(() => {
2914
- /* swallow — `onlyoffice_url` falls back to null */
2915
- });
2916
- }
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();
2917
3585
  });
2918
3586
 
2919
3587
  // --------------------------------------------------------------------
@@ -3136,6 +3804,29 @@ function openNode(n: FileNode) {
3136
3804
  void loadTrash();
3137
3805
  return;
3138
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
+ }
3139
3830
  if (n.type === 'dir') {
3140
3831
  // Multi-storage virtual rows have a bare path (`s3-test`); pass
3141
3832
  // them straight to load() which will treat them as the wire form
@@ -3710,7 +4401,7 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3710
4401
  ];
3711
4402
  }
3712
4403
  const isFile = single && sel[0]?.type === 'file';
3713
- const tagsLabel = locale.value === 'en' ? 'Tags…' : 'Etiketler…';
4404
+ const tagsLabel = t('ctx.tags_menu');
3714
4405
  const singleHasId = single && typeof sel[0]?.id === 'number';
3715
4406
  /* yildiz:s1 */
3716
4407
  const canStar = starableNodes(sel).length > 0;
@@ -3727,9 +4418,27 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3727
4418
  // Unified "Paylaş / İzinler" popup carries the public share link, per-user
3728
4419
  // permissions AND the folder-only "Dosya İste" (file-drop) tab — the user
3729
4420
  // picks the action from inside the modal, so there's no separate button.
3730
- 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;
3731
4440
  return [
3732
- { key: 'open', label: t('ctx.open'), hidden: !single },
4441
+ { key: 'open', label: t('ctx.open'), ...openGate, hidden: !single || openGate.hidden === true },
3733
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 */,
3734
4443
  { key: 'preview', label: t('ctx.preview'), hidden: !single, disabled: !isFile },
3735
4444
  /* tasi:m1 — Download works on ANY selection now. It used to disappear the
@@ -3741,7 +4450,13 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3741
4450
  decrypted IN THE BROWSER, one file at a time, and the server has no
3742
4451
  plaintext to zip. */
3743
4452
  { key: 'download', label: t('ctx.download'), hidden: !any || (e2eActive.value && !single), disabled: !any },
3744
- { 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
+ },
3745
4460
  /* tasi:m1 — VISIBLE and grey above a multi-selection, not gone. Sharing
3746
4461
  really is one item at a time (a share link addresses one node), and the
3747
4462
  row now says so in its tooltip; vanishing taught the reader that filex
@@ -3790,6 +4505,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3790
4505
  hidden: !canStar,
3791
4506
  },
3792
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),
3793
4512
  ...keepActionsFor(sel),
3794
4513
  { divider: true, key: 'sep2', label: '', hidden: !w },
3795
4514
  { key: 'delete', label: t('ctx.delete'), danger: true, hidden: !any || !w, disabled: !any },
@@ -3860,6 +4579,12 @@ function actingInPane(): boolean {
3860
4579
  }
3861
4580
 
3862
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
+ }
3863
4588
  switch (key) {
3864
4589
  /* gorunum:v1 — the selection bar's × . It used to be delivered by
3865
4590
  * synthesising a click on the listing's background, because nothing here
@@ -3939,7 +4664,7 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
3939
4664
  if (targets[0] && typeof targets[0].id === 'number') {
3940
4665
  const id = targets[0].id;
3941
4666
  navigator.clipboard?.writeText(String(id)).then(
3942
- () => flashToast(locale.value === 'en' ? `Node id ${id} copied` : `Node id ${id} kopyalandı`),
4667
+ () => flashToast(t('toast.node_id_copied', { id })),
3943
4668
  () => flashToast(`#${id}`),
3944
4669
  );
3945
4670
  }
@@ -4060,7 +4785,7 @@ async function paste() {
4060
4785
  }
4061
4786
  clipboard.value = { mode: null, items: [], sourcePath: null };
4062
4787
  } catch (err) {
4063
- emit('error', { message: (err as Error).message, context: { op: 'paste' } });
4788
+ reportMutationError(err, { op: 'paste' });
4064
4789
  }
4065
4790
  }
4066
4791
 
@@ -4069,7 +4794,7 @@ async function duplicate(n: FileNode) {
4069
4794
  const { op } = await api.copy([n.path], qualify(currentPath.value));
4070
4795
  pendingOps.register(op);
4071
4796
  } catch (err) {
4072
- emit('error', { message: (err as Error).message, context: { op: 'duplicate' } });
4797
+ reportMutationError(err, { op: 'duplicate' });
4073
4798
  }
4074
4799
  }
4075
4800
 
@@ -4204,6 +4929,12 @@ async function submitNewFolder(name: string) {
4204
4929
  async function submitRename(name: string) {
4205
4930
  const target = renameTarget.value;
4206
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;
4207
4938
  const inPane = mutationInPane.value; /* ui-fix — rename from the side pane */
4208
4939
  try {
4209
4940
  const dirWire = inPane ? qualify(splitPaneRef.value?.getPath() ?? '') : qualify(currentPath.value);
@@ -4222,7 +4953,11 @@ async function submitRename(name: string) {
4222
4953
  });
4223
4954
  }
4224
4955
  } catch (err) {
4225
- 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' });
4226
4961
  }
4227
4962
  }
4228
4963
 
@@ -4275,17 +5010,19 @@ async function confirmDelete() {
4275
5010
  if (inPane) void splitPaneRef.value?.reload();
4276
5011
  else selection.clear();
4277
5012
  } catch (err) {
4278
- emit('error', { message: (err as Error).message, context: { op: 'delete' } });
5013
+ reportMutationError(err, { op: 'delete' });
4279
5014
  }
4280
5015
  }
4281
5016
 
4282
5017
  function openConvert(n: FileNode) {
5018
+ // A shortcut or a stale menu must not open what the rule withholds.
5019
+ if (!legacyConvertUrl.value) return;
4283
5020
  convertTarget.value = n;
4284
5021
  showConvert.value = true;
4285
5022
  }
4286
5023
 
4287
5024
  function onConvertDone(name: string) {
4288
- flashToast(locale.value === 'en' ? `Converted → ${name}` : `Dönüştürüldü → ${name}`);
5025
+ flashToast(t('toast.converted_to', { name }));
4289
5026
  void load();
4290
5027
  }
4291
5028
 
@@ -4294,7 +5031,7 @@ function onConvertDone(name: string) {
4294
5031
 
4295
5032
  function triggerUpload() {
4296
5033
  if (!canWriteHere.value) {
4297
- flashToast(locale.value === 'en' ? 'Read-only here' : 'Burada yazma yetkiniz yok');
5034
+ flashToast(t('toast.read_only_here'));
4298
5035
  return;
4299
5036
  }
4300
5037
  fileInputEl.value?.click();
@@ -4570,7 +5307,7 @@ function onDropUpload(ev: DragEvent) {
4570
5307
  dragOver.value = false;
4571
5308
  // RBAC: block drag-drop upload where the user can't write.
4572
5309
  if (!canWriteHere.value) {
4573
- flashToast(locale.value === 'en' ? 'Read-only here' : 'Burada yazma yetkiniz yok');
5310
+ flashToast(t('toast.read_only_here'));
4574
5311
  return;
4575
5312
  }
4576
5313
  const list = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : [];
@@ -4821,7 +5558,7 @@ async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: s
4821
5558
  }
4822
5559
  selection.clear();
4823
5560
  } catch (err) {
4824
- emit('error', { message: (err as Error).message, context: { op: opLabel, targetDir } });
5561
+ reportMutationError(err, { op: opLabel, targetDir });
4825
5562
  }
4826
5563
  }
4827
5564
 
@@ -4956,6 +5693,12 @@ const { themeMode: themeModePref, setThemeMode: setThemeModePref } = useThemeMod
4956
5693
  const themeMode = computed<ThemeMode>(() =>
4957
5694
  themeModePref.value === 'host' ? props.config.theme || 'auto' : themeModePref.value,
4958
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 });
4959
5702
  // Resolved mode: an explicit choice wins, otherwise the OS preference — the
4960
5703
  // same logic variables.css encodes in CSS. The inline root variables beat every
4961
5704
  // stylesheet rule, so they must track this resolution at runtime.
@@ -5094,23 +5837,15 @@ function retryUploadJob(job: UploadJob) {
5094
5837
  }
5095
5838
  /* /wiring:c3 */
5096
5839
  /* === wiring:c4 — onboarding coach-mark tour ===
5097
- * First mount with no `filex.tourDone` flag auto-starts the tour (short
5098
- * delay so the listing/toolbar are laid out). Closing it — finished OR
5099
- * 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.
5100
5844
  * Restart arrives as a bubbled `fe:tour-restart` CustomEvent from the
5101
5845
  * Toolbar overflow menu, so no extra prop/emit threading through the
5102
5846
  * shared component tags is needed. */
5103
- const TOUR_LS_KEY = 'filex.tourDone';
5104
5847
  const showTour = ref(false);
5105
- let tourTimer: ReturnType<typeof setTimeout> | undefined;
5106
-
5107
- function tourAlreadyDone(): boolean {
5108
- try {
5109
- return localStorage.getItem(TOUR_LS_KEY) === '1';
5110
- } catch {
5111
- return true; // no storage → never auto-nag
5112
- }
5113
- }
5848
+ let cancelTourOffer: (() => void) | undefined;
5114
5849
 
5115
5850
  function startTour() {
5116
5851
  showTour.value = true;
@@ -5118,11 +5853,9 @@ function startTour() {
5118
5853
 
5119
5854
  function onTourClose() {
5120
5855
  showTour.value = false;
5121
- try {
5122
- localStorage.setItem(TOUR_LS_KEY, '1');
5123
- } catch {
5124
- /* private mode / quota */
5125
- }
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();
5126
5859
  rootEl.value?.focus();
5127
5860
  }
5128
5861
 
@@ -5132,14 +5865,12 @@ function onTourRestartEvent() {
5132
5865
 
5133
5866
  onMounted(() => {
5134
5867
  rootEl.value?.addEventListener('fe:tour-restart', onTourRestartEvent);
5135
- if (!tourAlreadyDone()) {
5136
- tourTimer = setTimeout(() => {
5137
- if (!showTour.value) startTour();
5138
- }, 900);
5139
- }
5868
+ cancelTourOffer = offerTourOnce(() => {
5869
+ if (!showTour.value) startTour();
5870
+ });
5140
5871
  });
5141
5872
  onBeforeUnmount(() => {
5142
- if (tourTimer) clearTimeout(tourTimer);
5873
+ cancelTourOffer?.();
5143
5874
  rootEl.value?.removeEventListener('fe:tour-restart', onTourRestartEvent);
5144
5875
  });
5145
5876
  /* === /wiring:c4 === */
@@ -5228,7 +5959,7 @@ function applyTabLocation(tb: TabState) {
5228
5959
  * onto one, so the tab's copy is redundant and is ignored. With it off,
5229
5960
  * nothing here changes at all and tabs keep exactly the behaviour they had.
5230
5961
  */
5231
- if (!folderMemoryEnabled() && tb.viewMode && tb.viewMode !== viewMode.value) {
5962
+ if (!folderMemoryOn.value && tb.viewMode && tb.viewMode !== viewMode.value) {
5232
5963
  viewMode.value = tb.viewMode;
5233
5964
  }
5234
5965
  /* ⚠ AFTER the load, not before. `load()` is async, so `currentPath` — and
@@ -5684,8 +6415,11 @@ async function transferItems(
5684
6415
  // "cross-storage is not supported" text here; it IS supported now, so
5685
6416
  // that text would mask the real cause (permissions, a read-only storage,
5686
6417
  // a full quota).
5687
- emit('error', { message: (err as Error).message, context: { op: 'transfer', targetWire } });
5688
- 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);
5689
6423
  return;
5690
6424
  }
5691
6425
  } else {
@@ -6253,6 +6987,7 @@ function closeRecoveryKey() {
6253
6987
  <div
6254
6988
  ref="rootEl"
6255
6989
  class="fe"
6990
+ :dir="dir"
6256
6991
  :class="{
6257
6992
  'fe--theme-light': themeMode === 'light',
6258
6993
  'fe--theme-dark': themeMode === 'dark',
@@ -6290,7 +7025,7 @@ function closeRecoveryKey() {
6290
7025
  bar's position can never describe two different panes. */"
6291
7026
  :selection-pane="activePaneId /* pane:p1 — which half the bar mounts into */"
6292
7027
  :paste-enabled="!!clipboard.mode"
6293
- :convert-enabled="!!effectiveConvertUrl"
7028
+ :convert-enabled="!!legacyConvertUrl"
6294
7029
  :can-go-up="canGoUp"
6295
7030
  :at-virtual-root="atVirtualRoot"
6296
7031
  :can-write="canWriteHere"
@@ -6358,6 +7093,7 @@ function closeRecoveryKey() {
6358
7093
  :narrow="isNarrow"
6359
7094
  :active-view="navView"
6360
7095
  :active-tag="navTag"
7096
+ :active-tag-kind="navTagKind"
6361
7097
  :tags="navTags"
6362
7098
  :tags-loaded="navTagsLoaded"
6363
7099
  :active-storage="adapter"
@@ -6365,6 +7101,7 @@ function closeRecoveryKey() {
6365
7101
  :shared-storages="sharedStorageNames"
6366
7102
  :trash-visible="config.trashVisible !== false"
6367
7103
  :show-connections="connectionsEnabled"
7104
+ :show-my-shares="mySharesEnabled /* paylas:m1 — off unless the host has the page */"
6368
7105
  :show-identity-surfaces="identitySurfaces"
6369
7106
  :can-write="canWriteHere && !atVirtualRoot && !trashActive"
6370
7107
  :locale="locale"
@@ -6383,6 +7120,9 @@ function closeRecoveryKey() {
6383
7120
  @new-folder="showNewFolder = true"
6384
7121
  @open-connections="openConnections"
6385
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"
6386
7126
  />
6387
7127
  <!-- The drawer's scrim. A button, not a div: dismissing an overlay by
6388
7128
  clicking beside it has to be reachable from the keyboard too. -->
@@ -6467,6 +7207,7 @@ function closeRecoveryKey() {
6467
7207
  :show-view-switcher="navView !== 'home'"
6468
7208
  :folder-key="currentFolderKey"
6469
7209
  :show-parent-path="!!searchQuery || crossFolderView"
7210
+ :trash="trashActive"
6470
7211
  :clipped="clippedPaths"
6471
7212
  :extra-filters="advFilters"
6472
7213
  :can-write="canWriteHere && !trashActive"
@@ -6520,6 +7261,16 @@ function closeRecoveryKey() {
6520
7261
 
6521
7262
  <!-- Strips that describe the WINDOW's state rather than the listing. -->
6522
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
+
6523
7274
  <!-- Live presence: who else is viewing this folder (empty → nothing shown).
6524
7275
  When the live socket is unavailable the same strip carries a small
6525
7276
  degraded-connection badge instead (presence is empty in fallback);
@@ -6650,6 +7401,9 @@ function closeRecoveryKey() {
6650
7401
  view rather than of the rows. -->
6651
7402
  <div v-if="trashMode && !loading" class="fe-trashbar">
6652
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>
6653
7407
  <!-- Offered only when the SERVER has said this caller may purge. The
6654
7408
  backend refuses regardless of what we draw; this is so nobody is
6655
7409
  handed a button that always fails. -->
@@ -6993,6 +7747,7 @@ function closeRecoveryKey() {
6993
7747
  not what is ticked in front of you, and it lives here. */"
6994
7748
  :locale="locale"
6995
7749
  :narrow="isNarrow"
7750
+ :caller-admin="callerAdmin /* the Node ID row is an administrator's (InspectorPanel) */"
6996
7751
  :thumb-src="thumbs.src"
6997
7752
  :api-base="props.config.apiBase ?? '' /* etiket:t1 — the details panel's
6998
7753
  Tags section mounts the same TagPicker the context menu opens, and
@@ -7005,9 +7760,11 @@ function closeRecoveryKey() {
7005
7760
  API breaks without it. */"
7006
7761
  :auth-headers="() => buildAuthHeaders()"
7007
7762
  :auth-credentials="api.credentialsMode()"
7008
- @tags-changed="onNodeTagsChanged /* etiket:t1 — drop the panel's cached
7009
- tag list when the details panel edits tags, exactly as the modal
7010
- 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)"
7011
7768
  @open-tag="openTagView /* etiket:t1 — a tag chip in this panel is a door to
7012
7769
  that tag's view, the same door the navigation panel's Tags section
7013
7770
  opens. */"
@@ -7041,10 +7798,8 @@ function closeRecoveryKey() {
7041
7798
  <ConnectionsPanel
7042
7799
  v-if="showConnections"
7043
7800
  :config="config"
7044
- initial-tab="connect"
7045
7801
  closable
7046
7802
  @close="closeOverlays"
7047
- @changed="() => load()"
7048
7803
  @error="onConnectionsError"
7049
7804
  />
7050
7805
  <template v-else>
@@ -7097,7 +7852,11 @@ function closeRecoveryKey() {
7097
7852
  :ops="pendingOps.ops.value"
7098
7853
  :locale="locale"
7099
7854
  :center="opsCenter"
7855
+ :output-mode-of="pluginOutputModeOf"
7856
+ :caller-admin="callerAdmin"
7100
7857
  @dismiss="(id) => pendingOps.dismiss(id)"
7858
+ @cancel="onCancelPendingOp"
7859
+ @open="onOpenOpOutput"
7101
7860
  />
7102
7861
 
7103
7862
  <OperationsCenter
@@ -7165,6 +7924,7 @@ function closeRecoveryKey() {
7165
7924
  typecheck. */"
7166
7925
  :only-office-ready="!!effectiveOnlyOfficeBase"
7167
7926
  :drawio-ready="!!effectiveDrawioUrl"
7927
+ :can-configure="callerAdmin"
7168
7928
  @close="showNewDocument = false"
7169
7929
  @created="onDocumentCreated"
7170
7930
  @error="emit('error', { message: $event.message, context: { op: 'newdoc' } })"
@@ -7233,6 +7993,7 @@ function closeRecoveryKey() {
7233
7993
  :open="showRename"
7234
7994
  :locale="locale"
7235
7995
  :current-name="renameTarget?.basename || ''"
7996
+ :error="renameError"
7236
7997
  @close="showRename = false"
7237
7998
  @submit="submitRename"
7238
7999
  />
@@ -7281,6 +8042,7 @@ function closeRecoveryKey() {
7281
8042
  :download-url="(p) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
7282
8043
  :only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 — OO cannot open ciphertext */"
7283
8044
  :only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
8045
+ :can-configure="callerAdmin"
7284
8046
  :new-tab-enabled="!e2eActive /* wiring:e2 — the standalone route pulls raw bytes */"
7285
8047
  :save-text-endpoint="e2eActive ? null : api.endpoints.saveText || null /* wiring:e2 — a plaintext save would be a leak */"
7286
8048
  :archive-list-endpoint="api.endpoints.archiveList || null"
@@ -7306,9 +8068,39 @@ function closeRecoveryKey() {
7306
8068
  @nav="onPreviewNav"
7307
8069
  @close="showPreview = false"
7308
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
+ />
7309
8100
  <ConvertModal
7310
- v-if="showConvert && convertTarget && effectiveConvertUrl"
7311
- :convert-url="effectiveConvertUrl"
8101
+ v-if="showConvert && convertTarget && legacyConvertUrl"
8102
+ :convert-url="legacyConvertUrl"
8103
+ :admin-note="callerAdmin ? t('convert.legacy_admin') : ''"
7312
8104
  :file-name="convertTarget?.basename || convertTarget?.path || ''"
7313
8105
  :fetch-bytes="() => api.fetchArrayBuffer(convertTarget?.path ?? '')"
7314
8106
  :upload="(f) => api.uploadMultipart(qualify(currentPath), [f]).then(() => {})"
@@ -7322,9 +8114,12 @@ function closeRecoveryKey() {
7322
8114
  :path="permTarget.path"
7323
8115
  :is-dir="permTarget.type === 'dir'"
7324
8116
  :size="typeof permTarget.size === 'number' ? permTarget.size : undefined"
7325
- :locale="locale === 'en' ? 'en' : 'tr'"
8117
+ :locale="locale"
7326
8118
  :share-max-ttl-days="shareMaxTtlDays"
7327
8119
  :initial-tab="permInitialTab /* surucu:d1 */"
8120
+ :mail-ready="capabilitiesData?.mail?.ready"
8121
+ :can-configure="callerAdmin"
8122
+ :perm="permTarget.perm || dirPerm"
7328
8123
  @close="showPerm = false; permInitialTab = undefined"
7329
8124
  />
7330
8125
 
@@ -7345,10 +8140,11 @@ function closeRecoveryKey() {
7345
8140
  >
7346
8141
  <div class="fe-recents__panel" @click.stop>
7347
8142
  <div class="fe-recents__header">
7348
- <strong>{{ locale === 'en' ? 'Recently opened' : 'Son açılanlar' }}</strong>
7349
- <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>
7350
8145
  </div>
7351
8146
  <RecentlyOpened
8147
+ :locale="locale"
7352
8148
  :api-base="props.config.apiBase ?? ''"
7353
8149
  :auth-headers="() => buildAuthHeaders()"
7354
8150
  :auth-credentials="api.credentialsMode()"
@@ -7372,9 +8168,9 @@ function closeRecoveryKey() {
7372
8168
  <div class="fe-modal__card fe-modal__card--md" @click.stop>
7373
8169
  <header class="fe-modal__head">
7374
8170
  <h2 class="fe-modal__title">
7375
- {{ locale === 'en' ? 'Tags' : 'Etiketler' }} — {{ tagPickerNode.basename }}
8171
+ {{ t('tags.title') }} — {{ tagPickerNode.basename }}
7376
8172
  </h2>
7377
- <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>
7378
8174
  </header>
7379
8175
  <div class="fe-modal__body">
7380
8176
  <TagPicker
@@ -7383,7 +8179,6 @@ function closeRecoveryKey() {
7383
8179
  :api-base="props.config.apiBase ?? ''"
7384
8180
  :auth-headers="() => buildAuthHeaders()"
7385
8181
  :auth-credentials="api.credentialsMode()"
7386
- @change="onNodeTagsChanged"
7387
8182
  @open="openTagView /* etiket:t1 — the SAME door as in the details
7388
8183
  panel; `openTagView` closes this dialog on the way, because
7389
8184
  leaving a modal open over the view it just navigated to is
@@ -7407,6 +8202,7 @@ function closeRecoveryKey() {
7407
8202
  :path-base="advPathBase"
7408
8203
  :content-search="advContentAvailable"
7409
8204
  :count="advSearchCount"
8205
+ :known-tags="advKnownTags"
7410
8206
  @close="showAdvSearch = false"
7411
8207
  @submit="applyAdvancedSearch"
7412
8208
  />
@@ -7511,6 +8307,7 @@ function closeRecoveryKey() {
7511
8307
  :download-url="(p: string) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
7512
8308
  :only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 */"
7513
8309
  :only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
8310
+ :can-configure="callerAdmin"
7514
8311
  :auth-headers="() => buildAuthHeaders({ 'Content-Type': 'application/json' })"
7515
8312
  :auth-credentials="api.credentialsMode()"
7516
8313
  :drawio-url="effectiveDrawioUrl"
@@ -7535,3 +8332,8 @@ function closeRecoveryKey() {
7535
8332
 
7536
8333
  <style src="./styles/variables.css"></style>
7537
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>