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