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