@brftech/filex-core 0.39.1 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -25
- package/dist/ArchiveViewer-BNqukFg8.js +124 -0
- package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
- package/dist/CsvViewer-ii_-MgmG.js +141 -0
- package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
- package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
- package/dist/EpubViewer-p4B8iiWb.js +163 -0
- package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
- package/dist/IpynbViewer-D_qYRJhu.js +184 -0
- package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
- package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
- package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
- package/dist/PsdViewer-CuV-OPSd.js +122 -0
- package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
- package/dist/TiffViewer-BLeoF4Be.js +142 -0
- package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
- package/dist/Viewer3D-DHhhU76E.js +75 -0
- package/dist/Viewer3D-DHhhU76E.js.map +1 -0
- package/dist/filex-core.js +217 -20077
- 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-BraG7Cz4.js +26664 -0
- package/dist/index-BraG7Cz4.js.map +1 -0
- package/dist/index.d.ts +4276 -851
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2170 -492
- 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 +57 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +939 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +162 -30
- package/src/components/GridView.vue +322 -58
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1158 -167
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +34 -5
- 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 +43 -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 +127 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +16 -4
- package/src/components/Toolbar.vue +965 -469
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +85 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useKeyboardShortcuts.ts +122 -0
- package/src/composables/useLocale.ts +365 -25
- package/src/index.ts +182 -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 +11 -1
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +152 -23
- package/src/lib/timezone.ts +456 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +432 -25
- package/src/locales/tr.ts +425 -25
- 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 +543 -473
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5435 -8
- package/src/styles/variables.css +140 -60
- 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();
|
|
@@ -2029,9 +2995,51 @@ useKeyboardShortcuts(rootEl, {
|
|
|
2029
2995
|
onTabNext: () => nextTab(),
|
|
2030
2996
|
onTabPrev: () => prevTab(),
|
|
2031
2997
|
/* /wiring:d1 */
|
|
2998
|
+
/* tus:t1 — the menu verbs, routed through the SAME dispatcher the right-click
|
|
2999
|
+
* menu and the toolbar use (dispatchItemAction), so a key and a click cannot
|
|
3000
|
+
* drift apart. The ones that act on a selection do nothing without one, which
|
|
3001
|
+
* is what the menu does too. */
|
|
3002
|
+
onNewFolder: () => {
|
|
3003
|
+
showNewFolder.value = true;
|
|
3004
|
+
},
|
|
3005
|
+
onUpload: () => triggerUpload(),
|
|
3006
|
+
onRefresh: () => void load(),
|
|
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()),
|
|
3013
|
+
onCopyPath: () => {
|
|
3014
|
+
const n = activeTargets()[0];
|
|
3015
|
+
if (n) void onCopyPath(n.path);
|
|
3016
|
+
},
|
|
3017
|
+
onCopyId: () => void dispatchItemAction('copy-id', activeTargets()),
|
|
3018
|
+
onRestore: () => void dispatchItemAction('restore', activeTargets()),
|
|
3019
|
+
/* /tus:t1 */
|
|
2032
3020
|
hasSelection: () => !selection.isEmpty.value,
|
|
2033
3021
|
});
|
|
2034
3022
|
|
|
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;
|
|
3040
|
+
return selection.nodes.value;
|
|
3041
|
+
}
|
|
3042
|
+
|
|
2035
3043
|
// --------------------------------------------------------------------
|
|
2036
3044
|
// Actions
|
|
2037
3045
|
// --------------------------------------------------------------------
|
|
@@ -2138,8 +3146,12 @@ async function restoreSelection(targets?: FileNode[]) {
|
|
|
2138
3146
|
const ids = nodes
|
|
2139
3147
|
.map((n) => (n as { id?: number }).id)
|
|
2140
3148
|
.filter((x): x is number => typeof x === 'number');
|
|
2141
|
-
const { restored } = await api.restoreIds(ids);
|
|
2142
|
-
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
|
+
);
|
|
2143
3155
|
selection.clear();
|
|
2144
3156
|
await loadTrash();
|
|
2145
3157
|
return;
|
|
@@ -2228,15 +3240,18 @@ const breadcrumbCtxPath = ref<string>('');
|
|
|
2228
3240
|
const paneCtxTargets = ref<FileNode[]>([]);
|
|
2229
3241
|
const breadcrumbCtxLabel = ref<string>('');
|
|
2230
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. */
|
|
2231
3246
|
const selectionMode = computed<SelectionMode>(() => {
|
|
2232
|
-
const sel =
|
|
3247
|
+
const sel = activeTargets();
|
|
2233
3248
|
if (sel.length === 0) return 'none';
|
|
2234
3249
|
if (sel.length === 1) return sel[0].type === 'dir' ? 'single-dir' : 'single-file';
|
|
2235
3250
|
return 'multi';
|
|
2236
3251
|
});
|
|
2237
3252
|
|
|
2238
3253
|
async function onToolbarAction(key: string) {
|
|
2239
|
-
const sel =
|
|
3254
|
+
const sel = activeTargets();
|
|
2240
3255
|
// The toolbar's "Aç" opens the in-page preview/editor modal (quick peek);
|
|
2241
3256
|
// everything else shares dispatchItemAction with the context menu so the two
|
|
2242
3257
|
// identical menus also behave identically.
|
|
@@ -2398,13 +3413,25 @@ function keepActionsFor(sel: FileNode[]): ContextAction[] {
|
|
|
2398
3413
|
const st = keepStateOf(keepRemoteOf(sel[0]!));
|
|
2399
3414
|
return [
|
|
2400
3415
|
{ divider: true, key: 'sep-keep', label: '' },
|
|
2401
|
-
{ key: 'keep-local', label: t('ctx.keep_local'),
|
|
2402
|
-
{ key: 'keep-online', label: t('ctx.keep_online'),
|
|
2403
|
-
{ key: 'keep-inherited', label: t('ctx.keep_inherited'),
|
|
2404
|
-
{ 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' },
|
|
2405
3420
|
];
|
|
2406
3421
|
}
|
|
2407
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
|
+
|
|
2408
3435
|
async function onContextTarget(node: FileNode, ev: MouseEvent) {
|
|
2409
3436
|
ctxMode.value = 'selection';
|
|
2410
3437
|
void refreshKept(); // menu labels react if the kept set changed since last look
|
|
@@ -2412,13 +3439,40 @@ async function onContextTarget(node: FileNode, ev: MouseEvent) {
|
|
|
2412
3439
|
selection.click(node.path);
|
|
2413
3440
|
await nextTick();
|
|
2414
3441
|
}
|
|
2415
|
-
|
|
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);
|
|
2416
3467
|
}
|
|
2417
3468
|
|
|
2418
3469
|
function onContextCanvas(ev: MouseEvent) {
|
|
2419
3470
|
ev.preventDefault();
|
|
2420
3471
|
ctxMode.value = 'selection';
|
|
2421
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 = [];
|
|
2422
3476
|
ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, []);
|
|
2423
3477
|
}
|
|
2424
3478
|
|
|
@@ -2429,27 +3483,45 @@ function onCrumbContext(payload: { x: number; y: number; adapterPath: string; la
|
|
|
2429
3483
|
ctxRef.value?.show({ clientX: payload.x, clientY: payload.y }, []);
|
|
2430
3484
|
}
|
|
2431
3485
|
|
|
2432
|
-
|
|
2433
|
-
*
|
|
2434
|
-
*
|
|
2435
|
-
*
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
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);
|
|
2446
3518
|
}
|
|
2447
3519
|
|
|
2448
3520
|
const contextActions = computed<ContextAction[]>(() => {
|
|
2449
3521
|
if (ctxMode.value === 'breadcrumb') {
|
|
2450
3522
|
return [
|
|
2451
|
-
{ key: 'open', label: t('ctx.open')
|
|
2452
|
-
{ key: 'copy-path', label: t('breadcrumb.copy_path')
|
|
3523
|
+
{ key: 'open', label: t('ctx.open') },
|
|
3524
|
+
{ key: 'copy-path', label: t('breadcrumb.copy_path') },
|
|
2453
3525
|
];
|
|
2454
3526
|
}
|
|
2455
3527
|
if (ctxMode.value === 'pane' /* ui-fix — side-pane menu is EXACTLY the main pane's */) {
|
|
@@ -2457,23 +3529,25 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
2457
3529
|
if (psel.length === 0) {
|
|
2458
3530
|
// Right-click on empty space: same as the main pane's canvas menu.
|
|
2459
3531
|
return [
|
|
2460
|
-
{ key: 'new-folder', label: t('toolbar.new_folder')
|
|
2461
|
-
{ 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 },
|
|
2462
3534
|
];
|
|
2463
3535
|
}
|
|
2464
3536
|
return selectionActionList(psel);
|
|
2465
3537
|
}
|
|
2466
3538
|
|
|
2467
|
-
|
|
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;
|
|
2468
3542
|
const any = sel.length > 0;
|
|
2469
3543
|
const single = sel.length === 1;
|
|
2470
3544
|
|
|
2471
3545
|
if (trashActive.value) {
|
|
2472
3546
|
if (!any) return [];
|
|
2473
3547
|
return [
|
|
2474
|
-
{ key: 'restore', label: t('ctx.restore')
|
|
3548
|
+
{ key: 'restore', label: t('ctx.restore') },
|
|
2475
3549
|
{ divider: true, key: 'sep1', label: '' },
|
|
2476
|
-
{ key: 'delete', label: t('ctx.delete_perm'),
|
|
3550
|
+
{ key: 'delete', label: t('ctx.delete_perm'), danger: true },
|
|
2477
3551
|
];
|
|
2478
3552
|
}
|
|
2479
3553
|
|
|
@@ -2495,8 +3569,8 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
2495
3569
|
if (!any) return [];
|
|
2496
3570
|
if (!single) return [];
|
|
2497
3571
|
return [
|
|
2498
|
-
{ key: 'open', label: t('ctx.open')
|
|
2499
|
-
{ 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 */,
|
|
2500
3574
|
// A whole storage can be kept too — that IS the "sync everything"
|
|
2501
3575
|
// shape, and it is one pair, not one per subfolder.
|
|
2502
3576
|
...keepActionsFor(sel),
|
|
@@ -2512,13 +3586,12 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
2512
3586
|
{
|
|
2513
3587
|
key: 'toggle-hidden',
|
|
2514
3588
|
label: showHiddenFiles.value ? t('ctx.hide_hidden') : t('ctx.show_hidden'),
|
|
2515
|
-
icon: showHiddenFiles.value ? '🙈' : '👁',
|
|
2516
3589
|
},
|
|
2517
3590
|
];
|
|
2518
3591
|
if (!permCanEdit(dirPerm.value)) return view;
|
|
2519
3592
|
return [
|
|
2520
|
-
{ key: 'new-folder', label: t('toolbar.new_folder')
|
|
2521
|
-
{ 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 },
|
|
2522
3595
|
...view,
|
|
2523
3596
|
];
|
|
2524
3597
|
}
|
|
@@ -2535,13 +3608,28 @@ const contextActions = computed<ContextAction[]>(() => {
|
|
|
2535
3608
|
function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
2536
3609
|
const any = sel.length > 0;
|
|
2537
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
|
+
}
|
|
2538
3627
|
const isFile = single && sel[0]?.type === 'file';
|
|
2539
3628
|
const tagsLabel = locale.value === 'en' ? 'Tags…' : 'Etiketler…';
|
|
2540
3629
|
const singleHasId = single && typeof sel[0]?.id === 'number';
|
|
2541
3630
|
/* yildiz:s1 */
|
|
2542
3631
|
const canStar = starableNodes(sel).length > 0;
|
|
2543
3632
|
const allStarred = selectionAllStarred(sel);
|
|
2544
|
-
const copyIdLabel = locale.value === 'en' ? 'Copy node id' : "Node id'yi kopyala";
|
|
2545
3633
|
// RBAC: gate mutating actions when the caller lacks edit on the target. The
|
|
2546
3634
|
// "İzinler" (permissions) action shows only for owners on RBAC-on storages.
|
|
2547
3635
|
const p = selPerm(sel);
|
|
@@ -2553,19 +3641,52 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
2553
3641
|
// picks the action from inside the modal, so there's no separate button.
|
|
2554
3642
|
const accessLabel = locale.value === 'en' ? 'Share / Permissions' : 'Paylaş / İzinler';
|
|
2555
3643
|
return [
|
|
2556
|
-
{ key: 'open', label: t('ctx.open'),
|
|
2557
|
-
{ key: 'open-tab', label: t('ctx.open_new_tab'),
|
|
2558
|
-
{ key: 'preview', label: t('ctx.preview'),
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
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. */
|
|
2564
3676
|
{ divider: true, key: 'sep1', label: '', hidden: !w },
|
|
2565
|
-
{ key: 'rename', label: t('ctx.rename'),
|
|
2566
|
-
{ key: 'cut', label: t('ctx.cut'),
|
|
2567
|
-
{ key: 'copy', label: t('ctx.copy'),
|
|
2568
|
-
|
|
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 },
|
|
2569
3690
|
{ divider: true, key: 'sep-meta', label: '', hidden: !singleHasId && !canStar },
|
|
2570
3691
|
/* yildiz:s1 — "star must be an action, like a tag" (owner, v0.30.0).
|
|
2571
3692
|
Beside Tags on purpose: they are the same kind of verb, and this is the
|
|
@@ -2574,13 +3695,13 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
2574
3695
|
{
|
|
2575
3696
|
key: 'star',
|
|
2576
3697
|
label: allStarred ? t('ctx.unstar') : t('ctx.star'),
|
|
2577
|
-
icon: allStarred ? '
|
|
3698
|
+
icon: allStarred ? 'unstar' : 'star' /* gorunum:v1-icons — names the glyph, not an emoji */,
|
|
2578
3699
|
hidden: !canStar,
|
|
2579
3700
|
},
|
|
2580
|
-
{ key: 'tags', label: tagsLabel,
|
|
3701
|
+
{ key: 'tags', label: tagsLabel, hidden: !singleHasId, disabled: !singleHasId },
|
|
2581
3702
|
...keepActionsFor(sel),
|
|
2582
3703
|
{ divider: true, key: 'sep2', label: '', hidden: !w },
|
|
2583
|
-
{ key: 'delete', label: t('ctx.delete'),
|
|
3704
|
+
{ key: 'delete', label: t('ctx.delete'), danger: true, hidden: !any || !w, disabled: !any },
|
|
2584
3705
|
];
|
|
2585
3706
|
}
|
|
2586
3707
|
|
|
@@ -2588,17 +3709,22 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
2588
3709
|
// two stay identical for a selection; the empty/trash/virtual-root cases match
|
|
2589
3710
|
// the context menu's special branches.
|
|
2590
3711
|
const toolbarActions = computed<ContextAction[]>(() => {
|
|
2591
|
-
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) : [];
|
|
2592
3718
|
if (trashActive.value) {
|
|
2593
3719
|
if (sel.length === 0) return [];
|
|
2594
3720
|
return [
|
|
2595
|
-
{ key: 'restore', label: t('ctx.restore')
|
|
2596
|
-
{ key: 'delete', label: t('ctx.delete_perm'),
|
|
3721
|
+
{ key: 'restore', label: t('ctx.restore') },
|
|
3722
|
+
{ key: 'delete', label: t('ctx.delete_perm'), danger: true },
|
|
2597
3723
|
];
|
|
2598
3724
|
}
|
|
2599
3725
|
const trimmedPath = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
2600
3726
|
if (multiStorageRoot.value && trimmedPath === '') {
|
|
2601
|
-
return sel.length === 1 ? [{ key: 'open', label: t('ctx.open')
|
|
3727
|
+
return sel.length === 1 ? [{ key: 'open', label: t('ctx.open') }] : [];
|
|
2602
3728
|
}
|
|
2603
3729
|
if (sel.length === 0) return [];
|
|
2604
3730
|
return selectionActionList(sel);
|
|
@@ -2624,8 +3750,34 @@ async function onContextAction(action: ContextAction, targets: FileNode[]) {
|
|
|
2624
3750
|
// the right-click menu (onContextAction) and the toolbar (onToolbarAction)
|
|
2625
3751
|
// route here, so the two menus that now render the SAME list also behave the
|
|
2626
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
|
+
|
|
2627
3771
|
async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
2628
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;
|
|
2629
3781
|
case 'open':
|
|
2630
3782
|
// Context-menu "Aç" launches the standalone fullscreen route
|
|
2631
3783
|
// in a new tab. Double-click (openNode) opens the in-page
|
|
@@ -2637,7 +3789,16 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2637
3789
|
if (targets[0]) previewNode(targets[0]);
|
|
2638
3790
|
break;
|
|
2639
3791
|
case 'download':
|
|
2640
|
-
|
|
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;
|
|
2641
3802
|
break;
|
|
2642
3803
|
case 'keep-local': {
|
|
2643
3804
|
const ds = desktopSync.value;
|
|
@@ -2674,9 +3835,6 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2674
3835
|
case 'convert':
|
|
2675
3836
|
if (targets[0]) openConvert(targets[0]);
|
|
2676
3837
|
break;
|
|
2677
|
-
case 'share':
|
|
2678
|
-
if (targets[0]) openShare(targets[0]);
|
|
2679
|
-
break;
|
|
2680
3838
|
case 'access':
|
|
2681
3839
|
if (targets[0]) {
|
|
2682
3840
|
permTarget.value = targets[0];
|
|
@@ -2704,20 +3862,20 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2704
3862
|
case 'rename':
|
|
2705
3863
|
if (targets[0]) {
|
|
2706
3864
|
renameTarget.value = targets[0];
|
|
2707
|
-
mutationInPane.value =
|
|
3865
|
+
mutationInPane.value = actingInPane(); /* ui-fix */
|
|
2708
3866
|
showRename.value = true;
|
|
2709
3867
|
}
|
|
2710
3868
|
break;
|
|
2711
3869
|
case 'cut':
|
|
2712
3870
|
/* ui-fix — in a pane context the clipboard source must be the pane's dir. */
|
|
2713
|
-
if (
|
|
3871
|
+
if (actingInPane()) paneCut();
|
|
2714
3872
|
else {
|
|
2715
3873
|
clipboard.value = { mode: 'cut', items: targets, sourcePath: currentPath.value };
|
|
2716
3874
|
flashToast(t('toast.cut_ready'));
|
|
2717
3875
|
}
|
|
2718
3876
|
break;
|
|
2719
3877
|
case 'copy':
|
|
2720
|
-
if (
|
|
3878
|
+
if (actingInPane()) paneCopy();
|
|
2721
3879
|
else {
|
|
2722
3880
|
clipboard.value = { mode: 'copy', items: targets, sourcePath: currentPath.value };
|
|
2723
3881
|
flashToast(t('toast.copy_ready'));
|
|
@@ -2726,13 +3884,20 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2726
3884
|
case 'paste':
|
|
2727
3885
|
/* ui-fix — pasting from the right-click menu goes to the active pane too
|
|
2728
3886
|
* (the keyboard shortcut was already pane-routed; the menu was not). */
|
|
2729
|
-
if (
|
|
3887
|
+
if (actingInPane()) await panePaste();
|
|
2730
3888
|
else await paste();
|
|
2731
3889
|
break;
|
|
2732
|
-
case 'delete':
|
|
2733
|
-
|
|
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;
|
|
2734
3898
|
showDelete.value = true;
|
|
2735
3899
|
break;
|
|
3900
|
+
}
|
|
2736
3901
|
case 'restore':
|
|
2737
3902
|
if (targets.length > 0) await restoreSelection(targets);
|
|
2738
3903
|
break;
|
|
@@ -2740,7 +3905,7 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2740
3905
|
toggleHiddenFiles();
|
|
2741
3906
|
break;
|
|
2742
3907
|
case 'new-folder':
|
|
2743
|
-
mutationInPane.value =
|
|
3908
|
+
mutationInPane.value = actingInPane(); /* ui-fix */
|
|
2744
3909
|
showNewFolder.value = true;
|
|
2745
3910
|
break;
|
|
2746
3911
|
case 'duplicate':
|
|
@@ -2786,10 +3951,17 @@ async function paste() {
|
|
|
2786
3951
|
const plan = resolveTransfer(items, targetWire, cb.mode === 'cut' ? 'move' : 'copy');
|
|
2787
3952
|
if (cb.mode === 'cut') {
|
|
2788
3953
|
const originWire = qualify(sourceDir) || undefined;
|
|
3954
|
+
const collides = await movedNamesCollide(items, targetWire);
|
|
2789
3955
|
const { op } = await api.moveAsync(items, targetWire, originWire);
|
|
2790
|
-
registerMoveUndo(op.id, items, targetWire, originWire);
|
|
3956
|
+
registerMoveUndo(op.id, items, targetWire, originWire, collides);
|
|
2791
3957
|
pendingOps.register(op);
|
|
2792
|
-
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
|
+
);
|
|
2793
3965
|
} else {
|
|
2794
3966
|
const { op } = await api.copy(items, targetWire);
|
|
2795
3967
|
pendingOps.register(op);
|
|
@@ -2826,8 +3998,105 @@ function downloadFile(n: FileNode) {
|
|
|
2826
3998
|
window.open(url, '_blank');
|
|
2827
3999
|
}
|
|
2828
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
|
+
|
|
2829
4079
|
// ------- Modals -------
|
|
2830
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
|
+
|
|
2831
4100
|
async function submitNewFolder(name: string) {
|
|
2832
4101
|
const inPane = mutationInPane.value; /* ui-fix — new folder in the side pane */
|
|
2833
4102
|
try {
|
|
@@ -2916,13 +4185,7 @@ async function confirmDelete() {
|
|
|
2916
4185
|
else selection.clear();
|
|
2917
4186
|
} catch (err) {
|
|
2918
4187
|
emit('error', { message: (err as Error).message, context: { op: 'delete' } });
|
|
2919
|
-
}
|
|
2920
|
-
}
|
|
2921
|
-
|
|
2922
|
-
function openShare(n: FileNode) {
|
|
2923
|
-
shareTarget.value = n;
|
|
2924
|
-
activeShare.value = null;
|
|
2925
|
-
showShare.value = true;
|
|
4188
|
+
}
|
|
2926
4189
|
}
|
|
2927
4190
|
|
|
2928
4191
|
function openConvert(n: FileNode) {
|
|
@@ -2935,32 +4198,6 @@ function onConvertDone(name: string) {
|
|
|
2935
4198
|
void load();
|
|
2936
4199
|
}
|
|
2937
4200
|
|
|
2938
|
-
async function submitShare(payload: {
|
|
2939
|
-
password: boolean;
|
|
2940
|
-
expires_at: string | null;
|
|
2941
|
-
max_downloads: number | null;
|
|
2942
|
-
}) {
|
|
2943
|
-
const target = shareTarget.value;
|
|
2944
|
-
if (!target) return;
|
|
2945
|
-
try {
|
|
2946
|
-
const { share } = await api.createShare({
|
|
2947
|
-
path: target.path, // qualified `<adapter>://<rel>`
|
|
2948
|
-
password: payload.password,
|
|
2949
|
-
expires_at: payload.expires_at,
|
|
2950
|
-
max_downloads: payload.max_downloads,
|
|
2951
|
-
});
|
|
2952
|
-
activeShare.value = share;
|
|
2953
|
-
emit('share-created', { path: target.path, url: share.url, pin: share.password_pin ?? null });
|
|
2954
|
-
} catch (err) {
|
|
2955
|
-
emit('error', { message: (err as Error).message, context: { op: 'share' } });
|
|
2956
|
-
}
|
|
2957
|
-
}
|
|
2958
|
-
|
|
2959
|
-
function closeShare() {
|
|
2960
|
-
showShare.value = false;
|
|
2961
|
-
shareTarget.value = null;
|
|
2962
|
-
activeShare.value = null;
|
|
2963
|
-
}
|
|
2964
4201
|
|
|
2965
4202
|
// ------- Upload -------
|
|
2966
4203
|
|
|
@@ -3325,19 +4562,49 @@ const clippedPaths = computed<Set<string>>(() => {
|
|
|
3325
4562
|
|
|
3326
4563
|
const FE_DND_MIME = 'application/x-brf-files';
|
|
3327
4564
|
|
|
3328
|
-
|
|
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) {
|
|
3329
4594
|
if (!ev.dataTransfer) return;
|
|
3330
4595
|
if (node.basename === '.trash') {
|
|
3331
4596
|
ev.preventDefault();
|
|
3332
4597
|
return;
|
|
3333
4598
|
}
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
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
|
|
3338
4604
|
.filter((n) => !clippedPaths.value.has(n.path))
|
|
3339
4605
|
.filter((n) => n.basename !== '.trash')
|
|
3340
4606
|
.map((n) => ({ path: n.path, basename: n.basename, type: n.type })); // qualified
|
|
4607
|
+
if (items.length === 0) return;
|
|
3341
4608
|
|
|
3342
4609
|
/* wiring:f1 — drag-out (to the desktop / another application).
|
|
3343
4610
|
When a shell is present the drag is ALWAYS an OS drag: folders and
|
|
@@ -3348,22 +4615,18 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
|
|
|
3348
4615
|
the app the drag is still a server-side move — the payload stays with us —
|
|
3349
4616
|
and the shell is told to "give up" so it doesn't watch the drives for
|
|
3350
4617
|
nothing. */
|
|
3351
|
-
if (dragOut.value
|
|
4618
|
+
if (dragOut.value) {
|
|
3352
4619
|
ev.preventDefault();
|
|
3353
|
-
beginNativeDrag(items,
|
|
4620
|
+
beginNativeDrag(items, dirWire);
|
|
3354
4621
|
void Promise.resolve(dragOut.value.start(items)).catch((err) => {
|
|
3355
4622
|
endNativeDrag();
|
|
3356
4623
|
cancelShellDrag();
|
|
3357
4624
|
emit('error', { message: (err as Error).message, context: { op: 'drag-out' } });
|
|
3358
4625
|
});
|
|
4626
|
+
void prepareDragOut(items);
|
|
3359
4627
|
return;
|
|
3360
4628
|
}
|
|
3361
4629
|
|
|
3362
|
-
ev.dataTransfer.setData(FE_DND_MIME, JSON.stringify(items));
|
|
3363
|
-
ev.dataTransfer.setData(FE_DND_SRC_MIME, qualify(currentPath.value)); /* wiring:d1 — cross-pane origin stamp */
|
|
3364
|
-
ev.dataTransfer.setData('text/plain', items.map((i) => i.path).join('\n'));
|
|
3365
|
-
ev.dataTransfer.effectAllowed = 'move';
|
|
3366
|
-
|
|
3367
4630
|
/* Single file + a cookie session: the browser's own download path
|
|
3368
4631
|
(DownloadURL) fetches the file onto the desktop at drop time; no
|
|
3369
4632
|
preparation is needed at all. In a bearer-token setup (the desktop app)
|
|
@@ -3373,8 +4636,6 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
|
|
|
3373
4636
|
const payload = downloadUrlPayload(items[0], api.downloadUrl(items[0].path), node.mime_type);
|
|
3374
4637
|
if (payload) ev.dataTransfer.setData('DownloadURL', payload);
|
|
3375
4638
|
}
|
|
3376
|
-
|
|
3377
|
-
if (dragOut.value && items.length > 0) void prepareDragOut(items);
|
|
3378
4639
|
}
|
|
3379
4640
|
|
|
3380
4641
|
/* === wiring:f1 — drag-out preparation ===
|
|
@@ -3448,19 +4709,24 @@ async function prepareDragOut(items: DragItem[], quiet = false): Promise<void> {
|
|
|
3448
4709
|
async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: string, originOverride?: string): Promise<void> {
|
|
3449
4710
|
try {
|
|
3450
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);
|
|
3451
4713
|
if (api.endpoints.moveAsync) {
|
|
3452
4714
|
const { op } = await api.moveAsync(sources, targetDir, originWire);
|
|
3453
|
-
registerMoveUndo(op.id, sources, targetDir, originWire);
|
|
4715
|
+
registerMoveUndo(op.id, sources, targetDir, originWire, collides);
|
|
3454
4716
|
pendingOps.register(op);
|
|
3455
|
-
flashToast(t('split.move_queued'));
|
|
4717
|
+
flashToast(collides ? t('toast.move_kept_both') : t('split.move_queued'));
|
|
3456
4718
|
} else {
|
|
3457
4719
|
await api.move(originWire, sources, targetDir);
|
|
3458
4720
|
await load();
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
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
|
+
}
|
|
3464
4730
|
}
|
|
3465
4731
|
selection.clear();
|
|
3466
4732
|
} catch (err) {
|
|
@@ -3856,12 +5122,34 @@ onMounted(() => {
|
|
|
3856
5122
|
});
|
|
3857
5123
|
|
|
3858
5124
|
function applyTabLocation(tb: TabState) {
|
|
3859
|
-
|
|
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);
|
|
3860
5148
|
if (tb.path === '.trash') {
|
|
3861
|
-
void loadTrash();
|
|
5149
|
+
void loadTrash().then(settle, settle);
|
|
3862
5150
|
return;
|
|
3863
5151
|
}
|
|
3864
|
-
void load(tb.path);
|
|
5152
|
+
void load(tb.path).then(settle, settle);
|
|
3865
5153
|
}
|
|
3866
5154
|
function activateTab(id: string) {
|
|
3867
5155
|
const tb = tabsApi.activate(id);
|
|
@@ -3927,19 +5215,106 @@ onBeforeUnmount(() => {
|
|
|
3927
5215
|
|
|
3928
5216
|
// ---- split (per-tab secondary pane) --------------------------------
|
|
3929
5217
|
|
|
3930
|
-
const
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
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,
|
|
3934
5265
|
);
|
|
3935
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
|
+
|
|
3936
5311
|
function toggleSplit() {
|
|
3937
5312
|
if (activeSplit.value) {
|
|
3938
5313
|
tabsApi.setSplit(null);
|
|
3939
5314
|
activePane.value = 'main';
|
|
3940
5315
|
return;
|
|
3941
5316
|
}
|
|
3942
|
-
if (
|
|
5317
|
+
if (!splitOffered.value) return;
|
|
3943
5318
|
tabsApi.setSplit({ path: currentPath.value ?? '', viewMode: viewMode.value });
|
|
3944
5319
|
}
|
|
3945
5320
|
function closeSplit() {
|
|
@@ -3948,7 +5323,36 @@ function closeSplit() {
|
|
|
3948
5323
|
}
|
|
3949
5324
|
function onPaneNavigate(p: string) {
|
|
3950
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 = [];
|
|
3951
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
|
+
|
|
3952
5356
|
/* ui-fix — when the trash row is opened from the side pane: the trash view
|
|
3953
5357
|
* (with its restore actions) belongs to the main pane → activate the main
|
|
3954
5358
|
* pane and open it there. */
|
|
@@ -3984,6 +5388,130 @@ watch(splitVisible, (v) => {
|
|
|
3984
5388
|
});
|
|
3985
5389
|
const paneIsActive = computed(() => activePane.value === 'split' && splitVisible.value);
|
|
3986
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
|
+
);
|
|
3987
5515
|
|
|
3988
5516
|
// Pane helpers — always wrap the main pane's existing converters.
|
|
3989
5517
|
function paneToUser(wire: string): string {
|
|
@@ -4000,13 +5528,13 @@ function paneClamp(p: string): string {
|
|
|
4000
5528
|
// selection and paste lands in the pane's folder. The state is SHARED with the
|
|
4001
5529
|
// main pane's — so cut-and-paste between panes works for free.
|
|
4002
5530
|
function paneCut() {
|
|
4003
|
-
const nodes =
|
|
5531
|
+
const nodes = splitSelection.nodes.value;
|
|
4004
5532
|
if (nodes.length === 0) return;
|
|
4005
5533
|
clipboard.value = { mode: 'cut', items: nodes, sourcePath: splitPaneRef.value?.getPath() ?? '' };
|
|
4006
5534
|
flashToast(t('toast.cut'));
|
|
4007
5535
|
}
|
|
4008
5536
|
function paneCopy() {
|
|
4009
|
-
const nodes =
|
|
5537
|
+
const nodes = splitSelection.nodes.value;
|
|
4010
5538
|
if (nodes.length === 0) return;
|
|
4011
5539
|
clipboard.value = { mode: 'copy', items: nodes, sourcePath: splitPaneRef.value?.getPath() ?? '' };
|
|
4012
5540
|
flashToast(t('toast.copied'));
|
|
@@ -4648,21 +6176,14 @@ function closeRecoveryKey() {
|
|
|
4648
6176
|
@drop="onDropUpload"
|
|
4649
6177
|
@contextmenu="onContextCanvas"
|
|
4650
6178
|
>
|
|
4651
|
-
<!-- wiring:d1 — tab strip
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
@select="activateTab"
|
|
4660
|
-
@close="closeTabById"
|
|
4661
|
-
@new="newTabHere"
|
|
4662
|
-
@reorder="(from: number, to: number) => tabsApi.move(from, to)"
|
|
4663
|
-
@toggle-split="toggleSplit"
|
|
4664
|
-
/>
|
|
4665
|
-
<!-- /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. -->
|
|
4666
6187
|
<Toolbar
|
|
4667
6188
|
ref="toolbarRef"
|
|
4668
6189
|
:view-mode="displayedViewMode /* ui-fix — the active pane's mode */"
|
|
@@ -4670,6 +6191,13 @@ function closeRecoveryKey() {
|
|
|
4670
6191
|
:trash-active="trashActive"
|
|
4671
6192
|
:actions="toolbarActions"
|
|
4672
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 */"
|
|
4673
6201
|
:paste-enabled="!!clipboard.mode"
|
|
4674
6202
|
:convert-enabled="!!effectiveConvertUrl"
|
|
4675
6203
|
:can-go-up="canGoUp"
|
|
@@ -4682,23 +6210,46 @@ function closeRecoveryKey() {
|
|
|
4682
6210
|
:nav-open="navToggleOn /* gezinti:g1 */"
|
|
4683
6211
|
:nav-enabled="sideNavEnabled /* gezinti:g1 */"
|
|
4684
6212
|
:view-modes="allowedViewModes /* gezinti:g1 */"
|
|
4685
|
-
:shell="driveShell ? 'drive' : 'classic' /* surucu:d1 */"
|
|
4686
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 */"
|
|
4687
6217
|
@open-palette="openPaletteWith /* surucu:d1 */"
|
|
4688
6218
|
@toggle-inspector="toggleInspector /* koru:k1 */"
|
|
4689
6219
|
@toggle-nav="toggleSideNav /* gezinti:g1 */"
|
|
4690
6220
|
@open-theme="showThemeGallery = true /* wiring:c1 */"
|
|
4691
6221
|
@update:view-mode="setDisplayedViewMode($event) /* ui-fix — to the active pane */"
|
|
4692
|
-
@update:search-query="
|
|
6222
|
+
@update:search-query="onToolbarSearch /* gorunum:v1-advsearch */"
|
|
6223
|
+
@open-advanced-search="openAdvancedSearch /* gorunum:v1-advsearch */"
|
|
4693
6224
|
@update:density="density = $event"
|
|
4694
6225
|
@open-shortcut-settings="showShortcutSettings = true /* wiring:c2 */"
|
|
6226
|
+
@open-timezone="showTimeZone = true /* zaman:z3 */"
|
|
4695
6227
|
@new-folder="showNewFolder = true"
|
|
4696
6228
|
@upload="triggerUpload"
|
|
4697
|
-
@refresh="
|
|
6229
|
+
@refresh="refreshAll /* gorunum:v2-topbar */"
|
|
4698
6230
|
@go-up="goUp"
|
|
4699
6231
|
@action="onToolbarAction"
|
|
4700
6232
|
@open-recents="showRecents = true"
|
|
4701
|
-
|
|
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>
|
|
4702
6253
|
|
|
4703
6254
|
<!-- koru:k1 — fe__main lays the listing body and the inspector panel out
|
|
4704
6255
|
as flex siblings (row). Without the inspector open it is visually
|
|
@@ -4726,14 +6277,15 @@ function closeRecoveryKey() {
|
|
|
4726
6277
|
:show-identity-surfaces="identitySurfaces"
|
|
4727
6278
|
:can-write="canWriteHere && !atVirtualRoot && !trashActive"
|
|
4728
6279
|
:locale="locale"
|
|
4729
|
-
:new-menu="driveShell /* surucu:d1 */"
|
|
4730
6280
|
:can-request-files="canWriteHere && !atVirtualRoot && !trashActive && !navView /* surucu:d1 */"
|
|
6281
|
+
:can-new-document="canNewDocument && !trashActive && !navView /* belge:n1 */"
|
|
6282
|
+
@new-document="showNewDocument = true"
|
|
4731
6283
|
:quota="quotaSnapshot /* surucu:d1 */"
|
|
4732
6284
|
:theme="themeMode /* surucu:d1 — the teleported New menu leaves .fe */"
|
|
4733
6285
|
@request-files="openFileRequest /* surucu:d1 */"
|
|
4734
6286
|
@toggle="toggleSideNav"
|
|
4735
6287
|
@close="closeNavDrawer"
|
|
4736
|
-
@open-view="
|
|
6288
|
+
@open-view="openNavDest /* gorunum:v3-shell */"
|
|
4737
6289
|
@open-tag="loadTagView"
|
|
4738
6290
|
@open-storage="openNavStorage"
|
|
4739
6291
|
@upload="triggerUpload"
|
|
@@ -4751,80 +6303,131 @@ function closeRecoveryKey() {
|
|
|
4751
6303
|
:aria-label="t('sidenav.close')"
|
|
4752
6304
|
@click="closeNavDrawer"
|
|
4753
6305
|
></button>
|
|
4754
|
-
<!--
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
<div
|
|
4771
|
-
<
|
|
4772
|
-
|
|
4773
|
-
:
|
|
4774
|
-
:
|
|
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"
|
|
4775
6352
|
:locale="locale"
|
|
4776
|
-
:
|
|
6353
|
+
:theme="themeMode"
|
|
6354
|
+
:focused="mainPaneFocus"
|
|
6355
|
+
:path="currentPath"
|
|
6356
|
+
:qualify="qualify"
|
|
6357
|
+
:to-user="paneToUser"
|
|
6358
|
+
:clamp="paneClamp"
|
|
4777
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"
|
|
4778
6394
|
@navigate="onNavigate"
|
|
4779
|
-
@
|
|
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"
|
|
4780
6406
|
@crumb-context="onCrumbContext"
|
|
6407
|
+
@copy-path="onCopyPath"
|
|
4781
6408
|
@crumb-drop="onCrumbDropInto"
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
fill="none"
|
|
4805
|
-
stroke="currentColor"
|
|
4806
|
-
stroke-width="1.8"
|
|
4807
|
-
stroke-linecap="round"
|
|
4808
|
-
aria-hidden="true"
|
|
4809
|
-
focusable="false"
|
|
4810
|
-
>
|
|
4811
|
-
<circle cx="12" cy="12" r="9" />
|
|
4812
|
-
<path d="M12 11v5" />
|
|
4813
|
-
<circle cx="12" cy="7.6" r="1" fill="currentColor" stroke="none" />
|
|
4814
|
-
</svg>
|
|
4815
|
-
</button>
|
|
4816
|
-
</div>
|
|
4817
|
-
</div>
|
|
4818
|
-
<FilterBar
|
|
4819
|
-
v-if="driveShell && !atVirtualRoot"
|
|
4820
|
-
:value="driveFilters"
|
|
4821
|
-
:locale="locale"
|
|
4822
|
-
:theme="themeMode"
|
|
4823
|
-
:shown="displayFiles.length"
|
|
4824
|
-
:total="files.length"
|
|
4825
|
-
@update:value="setDriveFilters"
|
|
4826
|
-
/>
|
|
4827
|
-
|
|
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>
|
|
4828
6431
|
<!-- Live presence: who else is viewing this folder (empty → nothing shown).
|
|
4829
6432
|
When the live socket is unavailable the same strip carries a small
|
|
4830
6433
|
degraded-connection badge instead (presence is empty in fallback);
|
|
@@ -4928,7 +6531,8 @@ function closeRecoveryKey() {
|
|
|
4928
6531
|
</div>
|
|
4929
6532
|
</div>
|
|
4930
6533
|
<div v-if="e2eUnlocked" class="fe-e2e-strip" role="status">
|
|
4931
|
-
|
|
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>
|
|
4932
6536
|
<span class="fe-e2e-strip__label">{{ t('e2e.strip.label') }}</span>
|
|
4933
6537
|
<!-- The way back for somebody who declined. Quiet, but present: a
|
|
4934
6538
|
refusal that could not be reversed without deleting the folder
|
|
@@ -4947,34 +6551,60 @@ function closeRecoveryKey() {
|
|
|
4947
6551
|
</div>
|
|
4948
6552
|
<!-- /wiring:e2 -->
|
|
4949
6553
|
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
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
|
+
/>
|
|
4975
6605
|
<!-- Dead deep link (404) or RBAC-hidden dir (403, shown identically):
|
|
4976
6606
|
a dedicated state instead of a misleading "this folder is empty". -->
|
|
4977
|
-
<div v-else-if="
|
|
6607
|
+
<div v-else-if="hostBodyState === 'notfound'" class="fe-state">
|
|
4978
6608
|
<svg
|
|
4979
6609
|
class="fe-state__art"
|
|
4980
6610
|
viewBox="0 0 120 100"
|
|
@@ -5000,46 +6630,11 @@ function closeRecoveryKey() {
|
|
|
5000
6630
|
</button>
|
|
5001
6631
|
</div>
|
|
5002
6632
|
</div>
|
|
5003
|
-
<!-- Listing failed (network / 5xx) with nothing else to show: retryable
|
|
5004
|
-
error state in the same visual language. -->
|
|
5005
|
-
<div v-else-if="loadError && files.length === 0" class="fe-state">
|
|
5006
|
-
<svg
|
|
5007
|
-
class="fe-state__art"
|
|
5008
|
-
viewBox="0 0 120 100"
|
|
5009
|
-
width="110"
|
|
5010
|
-
height="92"
|
|
5011
|
-
fill="none"
|
|
5012
|
-
stroke="currentColor"
|
|
5013
|
-
stroke-width="2"
|
|
5014
|
-
stroke-linecap="round"
|
|
5015
|
-
stroke-linejoin="round"
|
|
5016
|
-
aria-hidden="true"
|
|
5017
|
-
>
|
|
5018
|
-
<circle cx="60" cy="50" r="28" />
|
|
5019
|
-
<path d="M60 36v18" />
|
|
5020
|
-
<circle cx="60" cy="63" r="1.8" fill="currentColor" stroke="none" />
|
|
5021
|
-
<path d="M24 88h72" stroke-dasharray="3 5" />
|
|
5022
|
-
</svg>
|
|
5023
|
-
<p class="fe-state__title">{{ t('error.title') }}</p>
|
|
5024
|
-
<!-- wiring:c4 — friendly hint + collapsible technical detail; the raw
|
|
5025
|
-
error message used to sit in the hint slot and read like UI copy. -->
|
|
5026
|
-
<p class="fe-state__hint">{{ t('error.hint') }}</p>
|
|
5027
|
-
<div class="fe-state__actions">
|
|
5028
|
-
<button type="button" class="fe-btn fe-btn--primary" @click="retryLoad">
|
|
5029
|
-
{{ t('error.retry') }}
|
|
5030
|
-
</button>
|
|
5031
|
-
</div>
|
|
5032
|
-
<details class="fe-state__details">
|
|
5033
|
-
<summary class="fe-state__details-summary">{{ t('error.details') }}</summary>
|
|
5034
|
-
<pre class="fe-state__details-pre">{{ loadError }}</pre>
|
|
5035
|
-
</details>
|
|
5036
|
-
<!-- /wiring:c4 -->
|
|
5037
|
-
</div>
|
|
5038
6633
|
<!-- wiring:e2 — encrypted-folder lock screen: the listing is not
|
|
5039
6634
|
rendered until the correct password is entered. The password is
|
|
5040
6635
|
verified against the marker in the browser; it never reaches the
|
|
5041
6636
|
server. -->
|
|
5042
|
-
<div v-else
|
|
6637
|
+
<div v-else class="fe-state fe-e2e-lock">
|
|
5043
6638
|
<svg
|
|
5044
6639
|
class="fe-state__art"
|
|
5045
6640
|
viewBox="0 0 120 100"
|
|
@@ -5065,6 +6660,8 @@ function closeRecoveryKey() {
|
|
|
5065
6660
|
type="password"
|
|
5066
6661
|
class="fe-input fe-e2e-lock__input"
|
|
5067
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 */"
|
|
5068
6665
|
autocomplete="current-password"
|
|
5069
6666
|
:disabled="e2eUnlockBusy"
|
|
5070
6667
|
/>
|
|
@@ -5086,8 +6683,14 @@ function closeRecoveryKey() {
|
|
|
5086
6683
|
</button>
|
|
5087
6684
|
</div>
|
|
5088
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>
|
|
5089
6692
|
<!-- Search with zero hits — its own message, not "folder is empty". -->
|
|
5090
|
-
<div v-
|
|
6693
|
+
<div v-if="searchQuery" class="fe-state">
|
|
5091
6694
|
<svg
|
|
5092
6695
|
class="fe-state__art"
|
|
5093
6696
|
viewBox="0 0 120 100"
|
|
@@ -5111,7 +6714,7 @@ function closeRecoveryKey() {
|
|
|
5111
6714
|
how it fills up; "This folder is empty" would be wrong twice over,
|
|
5112
6715
|
because there is no folder and nothing to drop into it. -->
|
|
5113
6716
|
<div
|
|
5114
|
-
v-else-if="
|
|
6717
|
+
v-else-if="navView && navView !== 'trash'"
|
|
5115
6718
|
class="fe-state"
|
|
5116
6719
|
:data-testid="`empty-${navView}`"
|
|
5117
6720
|
>
|
|
@@ -5156,7 +6759,7 @@ function closeRecoveryKey() {
|
|
|
5156
6759
|
</p>
|
|
5157
6760
|
</div>
|
|
5158
6761
|
<!-- Empty trash view. -->
|
|
5159
|
-
<div v-else-if="
|
|
6762
|
+
<div v-else-if="trashMode" class="fe-state">
|
|
5160
6763
|
<svg
|
|
5161
6764
|
class="fe-state__art"
|
|
5162
6765
|
viewBox="0 0 120 100"
|
|
@@ -5175,41 +6778,15 @@ function closeRecoveryKey() {
|
|
|
5175
6778
|
<path d="M52 44v32M60 44v32M68 44v32" opacity="0.5" />
|
|
5176
6779
|
</svg>
|
|
5177
6780
|
<p class="fe-state__title">{{ t('empty.trash.title') }}</p>
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
<div
|
|
5183
|
-
v-else-if="!loading && filtersOn && displayFiles.length === 0 && files.length > 0"
|
|
5184
|
-
class="fe-state"
|
|
5185
|
-
data-testid="empty-filtered"
|
|
5186
|
-
>
|
|
5187
|
-
<svg
|
|
5188
|
-
class="fe-state__art"
|
|
5189
|
-
viewBox="0 0 120 100"
|
|
5190
|
-
width="110"
|
|
5191
|
-
height="92"
|
|
5192
|
-
fill="none"
|
|
5193
|
-
stroke="currentColor"
|
|
5194
|
-
stroke-width="2"
|
|
5195
|
-
stroke-linecap="round"
|
|
5196
|
-
stroke-linejoin="round"
|
|
5197
|
-
aria-hidden="true"
|
|
5198
|
-
>
|
|
5199
|
-
<path d="M26 28h68L68 58v24l-16 8V58z" />
|
|
5200
|
-
</svg>
|
|
5201
|
-
<p class="fe-state__title">{{ t('filter.empty.title') }}</p>
|
|
5202
|
-
<p class="fe-state__hint">{{ t('filter.empty.hint') }}</p>
|
|
5203
|
-
<div class="fe-state__actions">
|
|
5204
|
-
<button type="button" class="fe-btn" @click="clearDriveFilters">
|
|
5205
|
-
{{ t('filter.clear') }}
|
|
5206
|
-
</button>
|
|
5207
|
-
</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>
|
|
5208
6785
|
</div>
|
|
5209
6786
|
<!-- Loaded, zero files, no search: the real empty-folder state. The
|
|
5210
6787
|
upload affordances follow write permission (RBAC viewers only get
|
|
5211
6788
|
the title). -->
|
|
5212
|
-
<div v-else
|
|
6789
|
+
<div v-else class="fe-state">
|
|
5213
6790
|
<svg
|
|
5214
6791
|
class="fe-state__art"
|
|
5215
6792
|
viewBox="0 0 120 100"
|
|
@@ -5236,127 +6813,120 @@ function closeRecoveryKey() {
|
|
|
5236
6813
|
</button>
|
|
5237
6814
|
</div>
|
|
5238
6815
|
</div>
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
5248
|
-
:starred-ids="starredIds"
|
|
5249
|
-
:star-enabled="identitySurfaces"
|
|
5250
|
-
:api-base="props.config.apiBase ?? ''"
|
|
5251
|
-
:auth-headers="() => buildAuthHeaders()"
|
|
5252
|
-
:auth-credentials="api.credentialsMode()"
|
|
5253
|
-
@click-row="(n, m) => selection.click(n.path, m)"
|
|
5254
|
-
@dbl-row="openNode"
|
|
5255
|
-
@context-row="onContextTarget"
|
|
5256
|
-
@item-drag-start="onItemDragStart"
|
|
5257
|
-
@item-drop-into="onItemDropInto"
|
|
5258
|
-
@star-change="onStarChange"
|
|
5259
|
-
/>
|
|
5260
|
-
<GridView
|
|
5261
|
-
v-else-if="viewMode === 'grid' /* wiring:d2 — v-else → v-else-if (3. mod eklendi) */"
|
|
5262
|
-
:files="displayFiles /* surucu:d1 */"
|
|
5263
|
-
:sections="driveShell /* surucu:d1 */"
|
|
5264
|
-
:selected="selection.selected.value"
|
|
5265
|
-
:clipped="clippedPaths"
|
|
5266
|
-
:show-parent-path="!!searchQuery"
|
|
5267
|
-
:locale="locale"
|
|
5268
|
-
:loading="loading"
|
|
5269
|
-
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
5270
|
-
:thumb-src="thumbs.src"
|
|
5271
|
-
:starred-ids="starredIds"
|
|
5272
|
-
:star-enabled="identitySurfaces"
|
|
5273
|
-
:api-base="props.config.apiBase ?? ''"
|
|
5274
|
-
:auth-headers="() => buildAuthHeaders()"
|
|
5275
|
-
:auth-credentials="api.credentialsMode()"
|
|
5276
|
-
@click-card="(n, m) => selection.click(n.path, m)"
|
|
5277
|
-
@dbl-card="openNode"
|
|
5278
|
-
@context-card="onContextTarget"
|
|
5279
|
-
@item-drag-start="onItemDragStart"
|
|
5280
|
-
@item-drop-into="onItemDropInto"
|
|
5281
|
-
@star-change="onStarChange"
|
|
5282
|
-
/>
|
|
5283
|
-
<!-- wiring:d2 — gallery view (same event contract as GridView) -->
|
|
5284
|
-
<GalleryView
|
|
5285
|
-
v-else
|
|
5286
|
-
:files="displayFiles /* surucu:d1 */"
|
|
5287
|
-
:selected="selection.selected.value"
|
|
5288
|
-
:clipped="clippedPaths"
|
|
5289
|
-
:show-parent-path="!!searchQuery"
|
|
5290
|
-
:locale="locale"
|
|
5291
|
-
:loading="loading"
|
|
5292
|
-
:thumb-src="thumbs.src"
|
|
5293
|
-
:starred-ids="starredIds"
|
|
5294
|
-
:star-enabled="identitySurfaces"
|
|
5295
|
-
:api-base="props.config.apiBase ?? ''"
|
|
5296
|
-
:auth-headers="() => buildAuthHeaders()"
|
|
5297
|
-
:auth-credentials="api.credentialsMode()"
|
|
5298
|
-
@click-card="(n, m) => selection.click(n.path, m)"
|
|
5299
|
-
@dbl-card="openNode"
|
|
5300
|
-
@context-card="onContextTarget"
|
|
5301
|
-
@item-drag-start="onItemDragStart"
|
|
5302
|
-
@item-drop-into="onItemDropInto"
|
|
5303
|
-
@star-change="onStarChange"
|
|
5304
|
-
/>
|
|
5305
|
-
<!-- /wiring:d2 -->
|
|
5306
|
-
</div>
|
|
5307
|
-
</div><!-- /fe__primary ui-fix -->
|
|
5308
|
-
|
|
5309
|
-
<!-- wiring:d1 — per-tab split: the secondary pane on the right (off in
|
|
5310
|
-
narrow mode). :key is bound to the tab id — on a tab switch the pane
|
|
5311
|
-
remounts cleanly with its own location. -->
|
|
5312
|
-
<SecondaryPane
|
|
5313
|
-
: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
|
|
5314
6824
|
v-if="splitVisible && activeSplit"
|
|
5315
6825
|
ref="splitPaneRef"
|
|
5316
6826
|
:key="'split-' + tabsActiveId"
|
|
6827
|
+
pane-id="split"
|
|
6828
|
+
self-driven
|
|
6829
|
+
closable
|
|
5317
6830
|
:api="api"
|
|
5318
|
-
:initial-path="activeSplit.path"
|
|
5319
6831
|
:locale="locale"
|
|
6832
|
+
:theme="themeMode"
|
|
6833
|
+
:focused="paneIsActive"
|
|
6834
|
+
:path="activeSplit.path"
|
|
5320
6835
|
:qualify="qualify"
|
|
5321
6836
|
:to-user="paneToUser"
|
|
5322
6837
|
:clamp="paneClamp"
|
|
5323
|
-
:root-
|
|
6838
|
+
:root-path="rootPathProp"
|
|
5324
6839
|
:floor="rootFloor"
|
|
5325
6840
|
:multi-root="multiStorageRoot"
|
|
5326
6841
|
:virtual-rows="virtualStorageRows"
|
|
5327
|
-
:
|
|
5328
|
-
:
|
|
5329
|
-
:
|
|
5330
|
-
:
|
|
5331
|
-
:
|
|
5332
|
-
|
|
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()"
|
|
5333
6859
|
@activate="activePane = 'split'"
|
|
5334
6860
|
@close="closeSplit"
|
|
5335
|
-
@
|
|
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"
|
|
5336
6870
|
@transfer="onPaneTransfer"
|
|
5337
|
-
@
|
|
5338
|
-
@
|
|
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"
|
|
5339
6881
|
/>
|
|
5340
|
-
|
|
6882
|
+
|
|
6883
|
+
</div><!-- /fe__panes -->
|
|
6884
|
+
</div><!-- /fe__stack pane:p1 -->
|
|
6885
|
+
|
|
5341
6886
|
|
|
5342
6887
|
<!-- koru:k1 — inspector (details) panel; v-if keeps the closed state
|
|
5343
6888
|
free of any DOM. Narrow mode renders it as a full-size overlay. -->
|
|
5344
6889
|
<InspectorPanel
|
|
5345
6890
|
v-if="showInspector"
|
|
5346
6891
|
:api="api"
|
|
5347
|
-
: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. */"
|
|
5348
6896
|
:dir-label="inspectorDirLabel"
|
|
5349
|
-
:dir-count="
|
|
5350
|
-
: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. */"
|
|
5351
6901
|
:locale="locale"
|
|
5352
6902
|
:narrow="isNarrow"
|
|
5353
6903
|
:thumb-src="thumbs.src"
|
|
5354
|
-
:
|
|
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. */"
|
|
5355
6921
|
@close="closeInspector"
|
|
5356
6922
|
@share-created="onInspectorShareCreated /* surucu:d1 */"
|
|
5357
6923
|
@manage-permissions="onInspectorManage"
|
|
5358
6924
|
@toast="flashToast"
|
|
5359
|
-
@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. */"
|
|
5360
6930
|
/>
|
|
5361
6931
|
</div>
|
|
5362
6932
|
<!-- /koru:k1 fe__main -->
|
|
@@ -5405,7 +6975,14 @@ function closeRecoveryKey() {
|
|
|
5405
6975
|
|
|
5406
6976
|
<div v-if="dragOver" class="fe__dragover">
|
|
5407
6977
|
<div class="fe__dragover-card">
|
|
5408
|
-
|
|
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>
|
|
5409
6986
|
<p>{{ t('dropzone.hint') }}</p>
|
|
5410
6987
|
</div>
|
|
5411
6988
|
</div>
|
|
@@ -5480,6 +7057,25 @@ function closeRecoveryKey() {
|
|
|
5480
7057
|
@select="onContextAction"
|
|
5481
7058
|
/>
|
|
5482
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
|
+
/>
|
|
5483
7079
|
<NewFolderModal
|
|
5484
7080
|
:open="showNewFolder"
|
|
5485
7081
|
:locale="locale"
|
|
@@ -5488,6 +7084,26 @@ function closeRecoveryKey() {
|
|
|
5488
7084
|
@submit="submitNewFolder"
|
|
5489
7085
|
@encrypted="showNewFolder = false; showEncFolder = true /* wiring:e2 */"
|
|
5490
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
|
+
/>
|
|
5491
7107
|
<!-- wiring:e2 — encrypted-folder creation modal -->
|
|
5492
7108
|
<EncryptedFolderModal
|
|
5493
7109
|
:open="showEncFolder"
|
|
@@ -5527,6 +7143,35 @@ function closeRecoveryKey() {
|
|
|
5527
7143
|
@close="showRename = false"
|
|
5528
7144
|
@submit="submitRename"
|
|
5529
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>
|
|
5530
7175
|
<DeleteConfirmModal
|
|
5531
7176
|
:open="showDelete"
|
|
5532
7177
|
:locale="locale"
|
|
@@ -5534,15 +7179,6 @@ function closeRecoveryKey() {
|
|
|
5534
7179
|
@close="showDelete = false"
|
|
5535
7180
|
@confirm="confirmDelete"
|
|
5536
7181
|
/>
|
|
5537
|
-
<ShareModal
|
|
5538
|
-
:open="showShare"
|
|
5539
|
-
:locale="locale"
|
|
5540
|
-
:share="activeShare"
|
|
5541
|
-
:share-max-ttl-days="shareMaxTtlDays"
|
|
5542
|
-
@close="closeShare"
|
|
5543
|
-
@submit="submitShare"
|
|
5544
|
-
@toast="flashToast"
|
|
5545
|
-
/>
|
|
5546
7182
|
<PreviewModal
|
|
5547
7183
|
:open="showPreview"
|
|
5548
7184
|
:locale="locale"
|
|
@@ -5562,6 +7198,19 @@ function closeRecoveryKey() {
|
|
|
5562
7198
|
:pdf-worker-url="props.config.pdfWorkerUrl || null"
|
|
5563
7199
|
:pdf-save-url="props.config.pdfSaveUrl || null"
|
|
5564
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"
|
|
5565
7214
|
@close="showPreview = false"
|
|
5566
7215
|
/>
|
|
5567
7216
|
<ConvertModal
|
|
@@ -5636,10 +7285,15 @@ function closeRecoveryKey() {
|
|
|
5636
7285
|
<div class="fe-modal__body">
|
|
5637
7286
|
<TagPicker
|
|
5638
7287
|
:node-id="tagPickerNode.id"
|
|
7288
|
+
:locale="locale"
|
|
5639
7289
|
:api-base="props.config.apiBase ?? ''"
|
|
5640
7290
|
:auth-headers="() => buildAuthHeaders()"
|
|
5641
7291
|
:auth-credentials="api.credentialsMode()"
|
|
5642
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. */"
|
|
5643
7297
|
@error="(msg: string) => emit('error', { message: msg, context: { op: 'tags' } })"
|
|
5644
7298
|
/>
|
|
5645
7299
|
</div>
|
|
@@ -5647,6 +7301,22 @@ function closeRecoveryKey() {
|
|
|
5647
7301
|
</div>
|
|
5648
7302
|
</transition>
|
|
5649
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
|
+
|
|
5650
7320
|
<!-- cila:c wiring — command palette (Ctrl/Cmd+K) + shortcuts help (?) -->
|
|
5651
7321
|
<CommandPalette
|
|
5652
7322
|
:initial-query="paletteSeed /* surucu:d1 */"
|
|
@@ -5665,12 +7335,12 @@ function closeRecoveryKey() {
|
|
|
5665
7335
|
@upload="triggerUpload"
|
|
5666
7336
|
@toggle-view="setDisplayedViewMode(displayedViewMode === 'list' ? 'grid' : displayedViewMode === 'grid' ? 'gallery' : 'list') /* wiring:d2 + ui-fix — 3-mode cycle, to the active pane */"
|
|
5667
7337
|
@open-trash="loadTrash"
|
|
5668
|
-
@refresh="
|
|
7338
|
+
@refresh="refreshAll /* gorunum:v2-topbar */"
|
|
5669
7339
|
@go-up="goUp"
|
|
5670
7340
|
@open-theme="showThemeGallery = true /* wiring:int */"
|
|
5671
7341
|
@open-shortcut-settings="showShortcutSettings = true /* wiring:int */"
|
|
5672
7342
|
@start-tour="startTour() /* wiring:int */"
|
|
5673
|
-
:split-enabled="
|
|
7343
|
+
:split-enabled="splitOffered /* wiring:d1 — see splitOffered */"
|
|
5674
7344
|
@tab-new="newTabHere() /* wiring:d1 */"
|
|
5675
7345
|
@split-toggle="toggleSplit() /* wiring:d1 */"
|
|
5676
7346
|
/>
|
|
@@ -5723,6 +7393,14 @@ function closeRecoveryKey() {
|
|
|
5723
7393
|
</div>
|
|
5724
7394
|
</transition>
|
|
5725
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
|
+
|
|
5726
7404
|
<!-- wiring:c2 — shortcut settings modal + Space quick-look overlay -->
|
|
5727
7405
|
<ShortcutSettings
|
|
5728
7406
|
:open="showShortcutSettings"
|