@brftech/filex-core 0.42.1 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5241 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1207 -343
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +47 -17
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -56
  76. package/src/components/SSHKeysPanel.vue +79 -26
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -27
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +121 -0
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +522 -119
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +539 -155
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2895 -241
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +58 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-CLNevfPE.js +0 -124
  216. package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
  217. package/dist/CsvViewer-K8XA3z7N.js +0 -141
  218. package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
  219. package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
  220. package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
  221. package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
  222. package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
  223. package/dist/MermaidViewer-DqucH_io.js +0 -137
  224. package/dist/MermaidViewer-DqucH_io.js.map +0 -1
  225. package/dist/PsdViewer-CwiVENmq.js +0 -122
  226. package/dist/PsdViewer-CwiVENmq.js.map +0 -1
  227. package/dist/TiffViewer-CpewSVmQ.js +0 -142
  228. package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
  229. package/dist/Viewer3D-CIrOn63I.js.map +0 -1
  230. package/dist/index-Dx0IbBcI.js +0 -26831
  231. package/dist/index-Dx0IbBcI.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
@@ -29,6 +29,8 @@ const props = defineProps<{
29
29
  /** Upload the produced File into the current folder (api.uploadMultipart bound to dir). */
30
30
  upload: (file: File) => Promise<void>;
31
31
  locale: LocaleCode;
32
+ /** For an administrator: this converter is being retired, and by what. */
33
+ adminNote?: string;
32
34
  }>();
33
35
 
34
36
  const { t } = useLocale(() => props.locale);
@@ -96,10 +98,34 @@ function send(cmd: string, extra: Record<string, unknown> = {}, transfer: Transf
96
98
  });
97
99
  }
98
100
 
101
+ /**
102
+ * ⚠ The converter has to SAY it is there. The iframe posts `ready` when it
103
+ * has loaded; a converter that is down never does, and this modal used to sit
104
+ * on "Loading the converter…" for ever. 20 s is far longer than a healthy
105
+ * converter needs to boot and short enough that a person is not left
106
+ * wondering.
107
+ */
108
+ const READY_TIMEOUT_MS = 20000;
109
+ let readyTimer: ReturnType<typeof setTimeout> | undefined;
110
+
111
+ /** Every failure a person sees here is a sentence (owner, 2026-09-21: never
112
+ * the raw status or body — which is what `fetchBytes`/`upload` errors carry,
113
+ * `${status} ${statusText} — <json>`). The detail goes to the console. */
114
+ function failWith(key: string, detail: unknown): void {
115
+ console.warn('[filex] convert:', key, detail);
116
+ error.value = t(key);
117
+ status.value = 'error';
118
+ }
119
+
99
120
  function onMessage(ev: MessageEvent) {
100
121
  const d = ev.data;
101
122
  if (!d || d.source !== 'convert-embed') return;
102
- if (d.event === 'ready') { void loadFormats(); return; }
123
+ if (d.event === 'ready') {
124
+ if (readyTimer) clearTimeout(readyTimer);
125
+ readyTimer = undefined;
126
+ void loadFormats();
127
+ return;
128
+ }
103
129
  const p = pending.get(d.id);
104
130
  if (!p) return;
105
131
  pending.delete(d.id);
@@ -115,8 +141,7 @@ async function loadFormats() {
115
141
  error.value = t('convert.unsupported_input');
116
142
  }
117
143
  } catch (e) {
118
- error.value = (e as Error).message;
119
- status.value = 'error';
144
+ failWith('convert.unreachable', e);
120
145
  }
121
146
  }
122
147
 
@@ -124,8 +149,10 @@ async function doConvert() {
124
149
  if (!selectedTo.value || !fromFmt.value) return;
125
150
  status.value = 'converting';
126
151
  error.value = null;
152
+ let stage: 'read' | 'convert' | 'save' = 'read';
127
153
  try {
128
154
  const buf = await props.fetchBytes();
155
+ stage = 'convert';
129
156
  const res = await send(
130
157
  'convert',
131
158
  { name: props.fileName, bytes: buf, fromIndex: fromFmt.value.index, toIndex: selectedTo.value.index },
@@ -135,17 +162,28 @@ async function doConvert() {
135
162
  const ext = res.ext || selectedTo.value.ext || selectedTo.value.format;
136
163
  const outName = `${base}.${ext}`;
137
164
  const file = new File([res.bytes], outName, { type: selectedTo.value.mime || 'application/octet-stream' });
165
+ stage = 'save';
138
166
  await props.upload(file);
139
167
  status.value = 'done';
140
168
  emit('done', outName);
141
169
  } catch (e) {
142
- error.value = (e as Error).message;
143
- status.value = 'error';
170
+ failWith(
171
+ stage === 'read' ? 'convert.read_failed' : stage === 'save' ? 'convert.save_failed' : 'convert.failed',
172
+ e,
173
+ );
144
174
  }
145
175
  }
146
176
 
147
- onMounted(() => window.addEventListener('message', onMessage));
148
- onBeforeUnmount(() => window.removeEventListener('message', onMessage));
177
+ onMounted(() => {
178
+ window.addEventListener('message', onMessage);
179
+ readyTimer = setTimeout(() => {
180
+ if (status.value === 'loading') failWith('convert.unreachable', 'no ready message');
181
+ }, READY_TIMEOUT_MS);
182
+ });
183
+ onBeforeUnmount(() => {
184
+ window.removeEventListener('message', onMessage);
185
+ if (readyTimer) clearTimeout(readyTimer);
186
+ });
149
187
  </script>
150
188
 
151
189
  <template>
@@ -165,10 +203,18 @@ onBeforeUnmount(() => window.removeEventListener('message', onMessage));
165
203
  </button>
166
204
  </header>
167
205
 
206
+ <p v-if="adminNote" class="filex-cv__note" data-testid="convert-legacy-note">{{ adminNote }}</p>
207
+
168
208
  <div v-if="status === 'loading'" class="filex-cv__msg">
169
209
  {{ t('convert.loading') }}
170
210
  </div>
171
211
 
212
+ <!-- The converter never answered: there is nothing to pick from, so the
213
+ sentence stands alone rather than above an empty format list. -->
214
+ <div v-else-if="status === 'error' && formats.length === 0" class="filex-cv__msg filex-cv__err">
215
+ {{ error }}
216
+ </div>
217
+
172
218
  <div v-else-if="status === 'done'" class="filex-cv__msg filex-cv__ok">
173
219
  <p>
174
220
  <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
@@ -195,7 +241,7 @@ onBeforeUnmount(() => window.removeEventListener('message', onMessage));
195
241
  @click="selectedTo = f"
196
242
  >
197
243
  <b>{{ (f.ext || f.format).toUpperCase() }}</b>
198
- <small>{{ f.name }}</small>
244
+ <small><bdi>{{ f.name }}</bdi></small>
199
245
  </button>
200
246
  <div v-if="toList.length === 0" class="filex-cv__empty">
201
247
  {{ t('convert.no_format') }}
@@ -253,6 +299,11 @@ onBeforeUnmount(() => window.removeEventListener('message', onMessage));
253
299
  .filex-cv__x .fe-aicon { color: currentColor; }
254
300
  .filex-cv__ok .fe-icon { display: inline-flex; vertical-align: -0.2em; }
255
301
  .filex-cv__msg { padding: 28px; text-align: center; color: var(--fe-text-muted, #5a6475); }
302
+ .filex-cv__note {
303
+ margin: 12px 16px 0; padding: 8px 10px; border-radius: 8px; font-size: 12.5px; line-height: 1.45;
304
+ background: var(--fe-bg-elev, #f7f8fa); color: var(--fe-text-muted, #5a6475);
305
+ border: 1px solid var(--fe-border, #e2e6ed);
306
+ }
256
307
  .filex-cv__ok { color: #059669; }
257
308
  .filex-cv__src { padding: 12px 16px 0; font-size: 13px; color: var(--fe-text-muted, #5a6475); }
258
309
  .filex-cv__search {
@@ -266,7 +317,7 @@ onBeforeUnmount(() => window.removeEventListener('message', onMessage));
266
317
  }
267
318
  .filex-cv__fmt {
268
319
  display: flex; flex-direction: column; gap: 2px; align-items: flex-start;
269
- padding: 8px 10px; cursor: pointer; text-align: left;
320
+ padding: 8px 10px; cursor: pointer; text-align: start;
270
321
  border: 1px solid var(--fe-border, #e2e6ed); border-radius: 8px;
271
322
  background: var(--fe-bg-elev, #f7f8fa); color: inherit;
272
323
  }
@@ -274,11 +325,11 @@ onBeforeUnmount(() => window.removeEventListener('message', onMessage));
274
325
  .filex-cv__fmt.is-sel { border-color: #44c878; background: rgba(68, 200, 120, 0.12); }
275
326
  .filex-cv__empty { grid-column: 1 / -1; text-align: center; color: var(--fe-text-muted, #5a6475); padding: 16px; }
276
327
  .filex-cv__err { padding: 0 16px; color: var(--fe-danger, #dc2626); font-size: 13px; }
277
- .filex-cv__foot { padding: 12px 16px; border-top: 1px solid var(--fe-border, #e2e6ed); text-align: right; }
328
+ .filex-cv__foot { padding: 12px 16px; border-top: 1px solid var(--fe-border, #e2e6ed); text-align: end; }
278
329
  .filex-cv__convert {
279
330
  padding: 8px 18px; border: 0; border-radius: 8px; cursor: pointer;
280
331
  background: #44c878; color: #03200f; font-weight: 600; font-size: 13px;
281
332
  }
282
333
  .filex-cv__convert:disabled { opacity: 0.5; cursor: default; }
283
- .filex-cv__frame { position: absolute; width: 0; height: 0; border: 0; left: -9999px; }
334
+ .filex-cv__frame { position: absolute; width: 0; height: 0; border: 0; inset-inline-start: -9999px; }
284
335
  </style>
@@ -42,7 +42,7 @@ import Modal from './Modal.vue';
42
42
  import { useLocale } from '../composables/useLocale';
43
43
  import type { FileApi, ManagerResponse } from '../composables/useFileApi';
44
44
  import type { LocaleCode } from '../types/ExplorerConfig';
45
- import { iconTile } from '../lib/fileIcons';
45
+ import { fileIconTile, iconTile } from '../lib/fileIcons';
46
46
  import {
47
47
  DRIVES,
48
48
  type DestinationRow,
@@ -72,6 +72,13 @@ const props = defineProps<{
72
72
  moving?: string[];
73
73
  /** Titles the dialog and names its confirm button. */
74
74
  mode?: 'move' | 'copy' | 'choose';
75
+ /**
76
+ * What the answer is. `dir` (the default) is the folder you are standing in;
77
+ * `file` lists the files too and the answer is the one you tick — an app
78
+ * plugin's `file-chooser` asks for this. A file pick has no write check:
79
+ * the chooser reads, it never writes.
80
+ */
81
+ pick?: 'dir' | 'file';
75
82
  /** The parent is running the operation: the dialog stays up but inert. */
76
83
  busy?: boolean;
77
84
  }>();
@@ -91,6 +98,10 @@ const here = ref<ManagerResponse | undefined>(undefined);
91
98
  const failed = ref(false);
92
99
 
93
100
  const multiDrive = computed(() => (props.storages?.length ?? 0) > 1);
101
+ const drivesLabel = computed(() => t('destpicker.drives'));
102
+ const pickingFile = computed(() => props.pick === 'file');
103
+ /** The file ticked in this folder — file pick only. */
104
+ const picked = ref<string | null>(null);
94
105
 
95
106
  /**
96
107
  * One listing cache for the life of the dialog.
@@ -129,6 +140,7 @@ async function load(path: string): Promise<ManagerResponse | undefined> {
129
140
  async function goTo(path: string): Promise<void> {
130
141
  at.value = path;
131
142
  failed.value = false;
143
+ picked.value = null;
132
144
  if (path === DRIVES) {
133
145
  here.value = undefined;
134
146
  rows.value = driveRows(props.storages, props.moving);
@@ -142,7 +154,7 @@ async function goTo(path: string): Promise<void> {
142
154
  loading.value = false;
143
155
  here.value = resp;
144
156
  failed.value = !resp;
145
- rows.value = destinationRows(resp?.files, props.moving);
157
+ rows.value = destinationRows(resp?.files, props.moving, { files: pickingFile.value });
146
158
  }
147
159
 
148
160
  const parent = computed(() => parentOfWire(at.value, multiDrive.value));
@@ -158,9 +170,11 @@ const hereWritable = computed(() => {
158
170
  return permAllowsWrite(here.value.perm as string | undefined);
159
171
  });
160
172
 
161
- const canChoose = computed(
162
- () => !props.busy && at.value !== DRIVES && hereWritable.value && hereBlocked.value === null,
163
- );
173
+ const canChoose = computed(() => {
174
+ if (props.busy || at.value === DRIVES) return false;
175
+ if (pickingFile.value) return picked.value !== null;
176
+ return hereWritable.value && hereBlocked.value === null;
177
+ });
164
178
 
165
179
  /**
166
180
  * Is the line under the list a REFUSAL or a prompt?
@@ -170,11 +184,15 @@ const canChoose = computed(
170
184
  * colour, which is what happened the first time this was drawn, makes the very
171
185
  * first screen of the dialog look like something has gone wrong.
172
186
  */
173
- const reasonIsRefusal = computed(() => at.value !== DRIVES);
187
+ const reasonIsRefusal = computed(() => at.value !== DRIVES && !(pickingFile.value && !failed.value));
174
188
 
175
189
  /** The one line under the list that says why Choose is off, if it is. */
176
190
  const reason = computed<string>(() => {
177
191
  if (at.value === DRIVES) return t('destpicker.pick_a_drive');
192
+ if (pickingFile.value) {
193
+ if (failed.value) return t('destpicker.unreadable');
194
+ return picked.value === null ? t('destpicker.pick_a_file') : '';
195
+ }
178
196
  if (hereBlocked.value === 'self') return t('destpicker.blocked.self');
179
197
  if (hereBlocked.value === 'descendant') return t('destpicker.blocked.descendant');
180
198
  if (failed.value) return t('destpicker.unreadable');
@@ -185,16 +203,28 @@ const reason = computed<string>(() => {
185
203
  const title = computed(() => {
186
204
  if (props.mode === 'move') return t('destpicker.title.move');
187
205
  if (props.mode === 'copy') return t('destpicker.title.copy');
188
- return t('destpicker.title.choose');
206
+ return pickingFile.value ? t('destpicker.title.choose_file') : t('destpicker.title.choose');
189
207
  });
190
208
 
191
209
  const confirmLabel = computed(() => {
192
210
  if (props.mode === 'move') return t('destpicker.confirm.move');
193
211
  if (props.mode === 'copy') return t('destpicker.confirm.copy');
194
- return t('destpicker.confirm.choose');
212
+ return pickingFile.value ? t('destpicker.confirm.choose_file') : t('destpicker.confirm.choose');
195
213
  });
196
214
 
215
+ /** What the line under the list names: the ticked file, else the folder. */
216
+ const targetName = computed(() =>
217
+ pickingFile.value && picked.value !== null
218
+ ? labelOfWire(picked.value, drivesLabel.value)
219
+ : labelOfWire(at.value, drivesLabel.value),
220
+ );
221
+
222
+ function rowIcon(row: DestinationRow): string {
223
+ return row.kind === 'file' ? fileIconTile({ type: 'file', basename: row.label }) : iconTile('folder');
224
+ }
225
+
197
226
  function rowTitle(row: DestinationRow): string {
227
+ if (row.kind === 'file') return row.label;
198
228
  if (row.blocked === 'self') return t('destpicker.blocked.self');
199
229
  if (row.blocked === 'descendant') return t('destpicker.blocked.descendant');
200
230
  if (!row.writable) return t('destpicker.readonly');
@@ -202,6 +232,11 @@ function rowTitle(row: DestinationRow): string {
202
232
  }
203
233
 
204
234
  function openRow(row: DestinationRow): void {
235
+ // A file is the answer, not a place to go.
236
+ if (row.kind === 'file') {
237
+ picked.value = row.path;
238
+ return;
239
+ }
205
240
  // A blocked row is a dead end — everything under it is blocked too — so
206
241
  // walking into it would be navigation that cannot end anywhere.
207
242
  if (row.blocked) return;
@@ -210,10 +245,8 @@ function openRow(row: DestinationRow): void {
210
245
 
211
246
  function choose(): void {
212
247
  if (!canChoose.value) return;
213
- emit('pick', at.value);
248
+ emit('pick', pickingFile.value && picked.value !== null ? picked.value : at.value);
214
249
  }
215
-
216
- const drivesLabel = computed(() => t('destpicker.drives'));
217
250
  </script>
218
251
 
219
252
  <template>
@@ -278,22 +311,32 @@ const drivesLabel = computed(() => t('destpicker.drives'));
278
311
  <ul class="fe-destpick__rows" data-testid="destpicker-rows">
279
312
  <li v-if="loading" class="fe-destpick__note">{{ t('destpicker.loading') }}</li>
280
313
  <li v-else-if="failed" class="fe-destpick__note">{{ t('destpicker.unreadable') }}</li>
281
- <li v-else-if="!rows.length" class="fe-destpick__note">{{ t('destpicker.empty') }}</li>
314
+ <li v-else-if="!rows.length" class="fe-destpick__note">
315
+ {{ pickingFile ? t('destpicker.empty_files') : t('destpicker.empty') }}
316
+ </li>
282
317
  <li v-for="row in rows" :key="row.path">
283
318
  <button
284
319
  type="button"
285
320
  class="fe-destpick__row"
286
- :class="{ 'is-locked': !row.writable, 'is-blocked': !!row.blocked }"
321
+ :class="{
322
+ 'is-locked': !row.writable,
323
+ 'is-blocked': !!row.blocked,
324
+ 'is-file': row.kind === 'file',
325
+ 'is-picked': row.kind === 'file' && picked === row.path,
326
+ }"
287
327
  :disabled="!!row.blocked"
288
328
  :title="rowTitle(row)"
329
+ :aria-pressed="row.kind === 'file' ? picked === row.path : undefined"
289
330
  :data-testid="'destpicker-row-' + row.label"
290
331
  @click="openRow(row)"
291
332
  >
292
- <span class="fe-destpick__rowicon" aria-hidden="true" v-html="iconTile('folder')"></span>
293
- <span class="fe-destpick__rowname">{{ row.label }}</span>
333
+ <!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/fileIcons -->
334
+ <span class="fe-destpick__rowicon" aria-hidden="true" v-html="rowIcon(row)"></span>
335
+ <span class="fe-destpick__rowname"><bdi>{{ row.label }}</bdi></span>
294
336
  <span v-if="row.blocked" class="fe-destpick__rowtag">{{ t('destpicker.tag.blocked') }}</span>
295
337
  <span v-else-if="!row.writable" class="fe-destpick__rowtag">{{ t('destpicker.readonly') }}</span>
296
338
  <svg
339
+ v-if="row.kind === 'dir'"
297
340
  class="fe-destpick__rowinto"
298
341
  viewBox="0 0 24 24"
299
342
  fill="none"
@@ -319,7 +362,7 @@ const drivesLabel = computed(() => t('destpicker.drives'));
319
362
  {{ reason }}
320
363
  </p>
321
364
  <p v-else class="fe-destpick__target" data-testid="destpicker-target">
322
- {{ t('destpicker.target', { name: labelOfWire(at, drivesLabel) }) }}
365
+ {{ t('destpicker.target', { name: targetName }) }}
323
366
  </p>
324
367
  </div>
325
368
 
@@ -1,20 +1,47 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
3
  * Modal — tiny headless wrapper. Backdrop + centered card + ESC + autofocus.
4
+ *
5
+ * ⚠⚠ Every dialog the explorer draws is this one — rename, new folder, the
6
+ * delete confirmation, and the frame EVERY app plugin's screen opens in
7
+ * (PluginViewModal). What it does on Escape, on a click outside the card
8
+ * and with the focus is therefore what all of them do, and until v0.43.0
9
+ * it did none of the three for a dialog MOUNTED open:
10
+ *
11
+ * • the keys and the focus were wired in a `watch` on `open` without
12
+ * `immediate`, so a dialog created with `open` already true (the plugin
13
+ * frame is `v-if` + `:open="true"`) never heard Escape and never took
14
+ * the focus — the converter's dialog stayed up on Escape while the share
15
+ * dialog next to it closed (release-candidate sweep, 2026-09-21);
16
+ * • `closeOnBackdrop` was read as `!== false`, but Vue casts an absent
17
+ * boolean prop to `false`, so NO dialog closed on an outside click.
4
18
  */
5
19
  import { watch, onBeforeUnmount, ref } from 'vue';
20
+ import { isTopModal, popModal, pushModal } from '../lib/modalStack';
21
+ import { useLocale } from '../composables/useLocale';
6
22
 
7
- const props = defineProps<{
23
+ const props = withDefaults(defineProps<{
24
+ /** The explorer's language, for the close button's name. Absent = English. */
25
+ locale?: string;
8
26
  open: boolean;
9
27
  title?: string;
10
28
  size?: 'sm' | 'md' | 'lg' | 'xl';
29
+ /** A click outside the card closes the dialog (the default, as the share
30
+ * dialog and the admin panel's dialogs do). `false` for a dialog that
31
+ * must be answered — the recovery key a person has to write down. */
11
32
  closeOnBackdrop?: boolean;
12
33
  /** When true, drop the dialog chrome (backdrop tint, header, footer,
13
34
  * centered card with border-radius) and render the slot full-bleed.
14
35
  * Used by the standalone /files/edit route where the browser tab IS
15
36
  * the container — a modal frame on top of it just steals real estate
16
- * from the editor. ESC + emit('close') still wire through so the
17
- * parent route can window.close() the tab. */
37
+ * from the editor.
38
+ *
39
+ * ⚠ Escape does NOT close a chromeless dialog. There it would close the
40
+ * TAB (the route answers `close` with window.close()), and Escape is a
41
+ * key editors use for themselves — dismissing a suggestion list in the
42
+ * text editor must not throw the document away. It never did in
43
+ * practice (the route mounts the dialog open, see above); now it is a
44
+ * rule rather than an accident. */
18
45
  chromeless?: boolean;
19
46
  /**
20
47
  * gorunum:v1 — full-bleed overlay. Like `chromeless` it drops the dialog
@@ -35,7 +62,10 @@ const props = defineProps<{
35
62
  * rather than a child of the parent component, so plain DOM
36
63
  * inheritance isn't always enough). */
37
64
  theme?: 'light' | 'dark' | 'auto';
38
- }>();
65
+ }>(), {
66
+ closeOnBackdrop: true,
67
+ });
68
+ const { t } = useLocale(() => props.locale ?? 'en');
39
69
 
40
70
  const emit = defineEmits<{
41
71
  (e: 'close'): void;
@@ -54,29 +84,74 @@ const FOCUSABLE =
54
84
  'input:not([disabled]),select:not([disabled]),textarea:not([disabled]),' +
55
85
  'button:not([disabled]),a[href],[tabindex]:not([tabindex="-1"])';
56
86
 
87
+ /** This dialog's place in lib/modalStack: only the top one answers keys. */
88
+ const me = Symbol('fe-modal');
89
+ let wired = false;
90
+ let focusTimer: ReturnType<typeof setTimeout> | undefined;
91
+
92
+ /**
93
+ * The first thing to focus: something in the BODY when there is one, so a
94
+ * form's first box — or a plugin screen's first choice — has the cursor,
95
+ * not the header's ×; then the footer; then anything in the card (the ×).
96
+ */
97
+ function focusFirst() {
98
+ const card = cardEl.value;
99
+ if (!card) return;
100
+ const within = (sel: string) => card.querySelector(sel)?.querySelector<HTMLElement>(FOCUSABLE) ?? null;
101
+ const target = within('.fe-modal__body') ?? within('.fe-modal__actions') ?? card.querySelector<HTMLElement>(FOCUSABLE);
102
+ target?.focus();
103
+ }
104
+
105
+ function wire() {
106
+ if (wired) return;
107
+ wired = true;
108
+ prevFocus = (document.activeElement as HTMLElement | null) ?? null; /* wiring:c4 */
109
+ pushModal(me);
110
+ document.addEventListener('keydown', onKey);
111
+ // ⚠ The handle is kept and cleared on unwire: a dialog closed (or torn
112
+ // down, as a test environment is) inside these 30 ms must not reach for a
113
+ // document that is gone — vitest reported exactly that as an unhandled
114
+ // "document is not defined" once dialogs created open were wired.
115
+ focusTimer = setTimeout(() => {
116
+ focusTimer = undefined;
117
+ if (typeof document === 'undefined') return;
118
+ // Only if the focus is not already inside (a component that focuses its
119
+ // own field on mount keeps it).
120
+ if (wired && !cardEl.value?.contains(document.activeElement)) focusFirst();
121
+ }, 30);
122
+ }
123
+
124
+ function unwire() {
125
+ if (!wired) return;
126
+ wired = false;
127
+ if (focusTimer !== undefined) {
128
+ clearTimeout(focusTimer);
129
+ focusTimer = undefined;
130
+ }
131
+ document.removeEventListener('keydown', onKey);
132
+ popModal(me);
133
+ /* wiring:c4 — return focus to the opener. */
134
+ prevFocus?.focus?.();
135
+ prevFocus = null;
136
+ }
137
+
138
+ // ⚠ `immediate`: a dialog created open (v-if + :open="true", which is how
139
+ // the explorer mounts every plugin screen) must be wired exactly like one
140
+ // that opens later. See the header for what happened without it.
57
141
  watch(
58
142
  () => props.open,
59
- (v) => {
60
- if (v) {
61
- prevFocus = (document.activeElement as HTMLElement | null) ?? null; /* wiring:c4 */
62
- document.addEventListener('keydown', onKey);
63
- setTimeout(() => {
64
- const focusable = cardEl.value?.querySelector<HTMLElement>(FOCUSABLE);
65
- focusable?.focus();
66
- }, 30);
67
- } else {
68
- document.removeEventListener('keydown', onKey);
69
- /* wiring:c4 — return focus to the opener. */
70
- prevFocus?.focus?.();
71
- prevFocus = null;
72
- }
73
- },
143
+ (v) => (v ? wire() : unwire()),
144
+ { immediate: true },
74
145
  );
75
146
 
76
- onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
147
+ // Removed while still open (the parent's v-if went false first): the keys
148
+ // come off and the focus goes home, as if it had closed.
149
+ onBeforeUnmount(unwire);
77
150
 
78
151
  function onKey(e: KeyboardEvent) {
152
+ if (!isTopModal(me)) return;
79
153
  if (e.key === 'Escape') {
154
+ if (props.chromeless) return;
80
155
  emit('close');
81
156
  return;
82
157
  }
@@ -100,8 +175,22 @@ function onKey(e: KeyboardEvent) {
100
175
  }
101
176
  }
102
177
 
103
- function onBackdrop() {
104
- if (props.closeOnBackdrop !== false) emit('close');
178
+ /**
179
+ * A click outside the card closes the dialog — but only a click that also
180
+ * STARTED outside it. A drag that selects text in a field and is released
181
+ * over the backdrop fires `click` on the backdrop (the common ancestor), and
182
+ * closing then would throw away what was being typed.
183
+ */
184
+ let downOnBackdrop = false;
185
+ function onBackdropDown(e: PointerEvent) {
186
+ downOnBackdrop = e.target === e.currentTarget;
187
+ }
188
+ function onBackdrop(e: MouseEvent) {
189
+ const started = downOnBackdrop;
190
+ downOnBackdrop = false;
191
+ if (!props.closeOnBackdrop || props.chromeless) return;
192
+ if (e.target !== e.currentTarget || !started) return;
193
+ emit('close');
105
194
  }
106
195
  </script>
107
196
 
@@ -117,6 +206,7 @@ function onBackdrop() {
117
206
  theme === 'dark' ? 'fe--theme-dark' : '',
118
207
  ]"
119
208
  role="presentation"
209
+ @pointerdown="onBackdropDown"
120
210
  @click="onBackdrop"
121
211
  >
122
212
  <div
@@ -138,7 +228,7 @@ function onBackdrop() {
138
228
  <button
139
229
  type="button"
140
230
  class="fe-modal__close"
141
- aria-label="Close"
231
+ :aria-label="t('modal.close')"
142
232
  @click="emit('close')"
143
233
  >×</button>
144
234
  </header>
@@ -59,6 +59,7 @@ import { iconTile, iconFamilyFor, typeLabelFor } from '../lib/fileIcons';
59
59
  import { crumbsOfWire, permAllowsWrite, splitWire } from '../lib/destinationTree';
60
60
  import Modal from './Modal.vue';
61
61
  import DestinationPickerModal from './DestinationPickerModal.vue';
62
+ import { inlineKeyStep } from '../lib/direction';
62
63
 
63
64
  const props = defineProps<{
64
65
  open: boolean;
@@ -79,6 +80,9 @@ const props = defineProps<{
79
80
  /** Resolved by the host: is there a usable OnlyOffice / drawio? */
80
81
  onlyOfficeReady?: boolean;
81
82
  drawioReady?: boolean;
83
+ /** Could this person set a missing service up (`capabilities.caller_admin`)?
84
+ * Adds WHERE to the line that says which families are missing. */
85
+ canConfigure?: boolean;
82
86
  }>();
83
87
 
84
88
  const emit = defineEmits<{
@@ -87,7 +91,7 @@ const emit = defineEmits<{
87
91
  (e: 'error', payload: { message: string }): void;
88
92
  }>();
89
93
 
90
- const { t } = useLocale(() => props.locale);
94
+ const { t, dir } = useLocale(() => props.locale);
91
95
 
92
96
  /* ================================================================== types */
93
97
 
@@ -117,9 +121,13 @@ const withheld = computed(() => {
117
121
  * ("...here.Diagrams need...") — measured in a browser, invisible in a unit
118
122
  * test that asserts on textContent.
119
123
  */
120
- const withheldText = computed(() =>
121
- withheld.value.map((svc) => t('newdoc.withheld.' + svc)).join(' '),
122
- );
124
+ const withheldText = computed(() => {
125
+ const parts = withheld.value.map((svc) => t('newdoc.withheld.' + svc));
126
+ /* Someone who can fix it is told where (the owner's rule for a missing
127
+ service, lib/serviceGate); everybody else reads only what is missing. */
128
+ if (parts.length && props.canConfigure) parts.push(t('newdoc.withheld_admin'));
129
+ return parts.join(' ');
130
+ });
123
131
 
124
132
  /** Sections, in the order the server listed them. */
125
133
  const groups = computed(() => {
@@ -313,8 +321,10 @@ function onTileKey(e: KeyboardEvent, ext: string) {
313
321
  const list = offered.value.map((ty) => ty.ext);
314
322
  const i = list.indexOf(ext);
315
323
  let next = -1;
316
- if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % list.length;
317
- else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (i - 1 + list.length) % list.length;
324
+ // ⚠ RTL: ← / → move the way they point — in a right-to-left grid the next
325
+ // tile is to the LEFT (lib/direction).
326
+ const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : inlineKeyStep(e.key, dir.value);
327
+ if (step) next = (i + step + list.length) % list.length;
318
328
  else if (e.key === 'Home') next = 0;
319
329
  else if (e.key === 'End') next = list.length - 1;
320
330
  if (next < 0) return;
@@ -3,6 +3,7 @@ import { ref, watch } from 'vue';
3
3
  import type { LocaleCode } from '../types/ExplorerConfig';
4
4
  import { useLocale } from '../composables/useLocale';
5
5
  import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
6
+ import { isInternalName } from '../lib/internalPaths';
6
7
  import Modal from './Modal.vue';
7
8
 
8
9
  const props = defineProps<{
@@ -42,6 +43,13 @@ function submit() {
42
43
  err.value = t('modal.newfolder.invalid');
43
44
  return;
44
45
  }
46
+ // The server refuses these (403 RESERVED_NAME, handlers/reserved_guard.go)
47
+ // in English; saying it here, in the reader's language, is the courtesy —
48
+ // the server is still the check.
49
+ if (isInternalName(clean)) {
50
+ err.value = t('names.reserved', { name: clean });
51
+ return;
52
+ }
45
53
  emit('submit', clean);
46
54
  }
47
55
  </script>