@brftech/filex-core 0.40.0 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -25
- package/dist/ArchiveViewer-DxxjhYZD.js +124 -0
- package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
- package/dist/CsvViewer-CyuwENI_.js +141 -0
- package/dist/CsvViewer-CyuwENI_.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
- package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
- package/dist/EpubViewer-K9PjCMmS.js +163 -0
- package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
- package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
- package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
- package/dist/MermaidViewer-Bp73aId8.js +137 -0
- package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
- package/dist/PsdViewer-C2eLivw1.js +122 -0
- package/dist/PsdViewer-C2eLivw1.js.map +1 -0
- package/dist/TiffViewer-DsHg5gcz.js +142 -0
- package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
- package/dist/Viewer3D-C_dc2HN9.js +75 -0
- package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
- package/dist/filex-core.js +218 -20361
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +61 -62
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-vxMZpYb-.js +26700 -0
- package/dist/index-vxMZpYb-.js.map +1 -0
- package/dist/index.d.ts +4212 -862
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2154 -506
- package/src/components/AdvancedSearch.vue +870 -0
- package/src/components/Breadcrumb.vue +191 -19
- package/src/components/CommandPalette.vue +66 -22
- package/src/components/ConnectionsPanel.vue +6 -2
- package/src/components/ContextMenu.vue +39 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +951 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +172 -56
- package/src/components/GridView.vue +331 -82
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1173 -194
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +26 -1
- package/src/components/RecentlyOpened.vue +17 -2
- package/src/components/S3KeysPanel.vue +7 -4
- package/src/components/SSHKeysPanel.vue +7 -4
- package/src/components/ShortcutSettings.vue +6 -2
- package/src/components/ShortcutsHelp.vue +4 -1
- package/src/components/SideNav.vue +206 -140
- package/src/components/StorageFields.vue +19 -1
- package/src/components/TabBar.vue +87 -1
- package/src/components/TagPicker.vue +77 -6
- package/src/components/ThemeGallery.vue +13 -198
- package/src/components/ThemePalette.vue +244 -0
- package/src/components/TimeZoneDialog.vue +135 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +35 -14
- package/src/components/Toolbar.vue +953 -465
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +89 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useLocale.ts +382 -26
- package/src/composables/useRowTouch.ts +95 -0
- package/src/index.ts +177 -3
- package/src/lib/actionIcons.ts +330 -0
- package/src/lib/advSearch.ts +162 -0
- package/src/lib/dateGroups.ts +197 -0
- package/src/lib/destinationTree.ts +235 -0
- package/src/lib/downloadSelection.ts +154 -0
- package/src/lib/fileFilters.ts +319 -12
- package/src/lib/fileIcons.ts +299 -5
- package/src/lib/filePreview.ts +605 -0
- package/src/lib/listing.ts +72 -0
- package/src/lib/shareTtl.ts +31 -2
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +151 -116
- package/src/lib/timezone.ts +501 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +416 -26
- package/src/locales/tr.ts +409 -26
- package/src/modals/ConvertModal.vue +23 -3
- package/src/modals/DestinationPickerModal.vue +341 -0
- package/src/modals/Modal.vue +18 -4
- package/src/modals/NewDocumentModal.vue +525 -0
- package/src/modals/NewFolderModal.vue +15 -9
- package/src/modals/PermissionsModal.vue +557 -476
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5409 -8
- package/src/styles/variables.css +139 -65
- package/src/types/Connections.ts +9 -0
- package/src/types/ExplorerConfig.ts +144 -39
- package/src/types/FileNode.ts +28 -0
- package/src/viewers/ArchiveViewer.vue +46 -8
- package/src/viewers/CsvViewer.vue +19 -2
- package/src/viewers/DrawioViewer.vue +12 -1
- package/src/viewers/EpubViewer.vue +50 -6
- package/src/viewers/IpynbViewer.vue +19 -2
- package/src/viewers/MermaidViewer.vue +20 -3
- package/src/viewers/PdfViewer.vue +24 -4
- package/src/viewers/PsdViewer.vue +28 -4
- package/src/viewers/TiffViewer.vue +19 -2
- package/src/viewers/Viewer3D.vue +36 -2
- package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
- package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
- package/dist/CsvViewer-CqWeV8VO.js +0 -131
- package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
- package/dist/DrawioViewer-BNALOB04.js.map +0 -1
- package/dist/EpubViewer-BPmlImG7.js +0 -145
- package/dist/EpubViewer-BPmlImG7.js.map +0 -1
- package/dist/IpynbViewer-CDlQmuCV.js +0 -175
- package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
- package/dist/MermaidViewer-sfZWdR-d.js +0 -127
- package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
- package/dist/PsdViewer-DwuySZsb.js +0 -112
- package/dist/PsdViewer-DwuySZsb.js.map +0 -1
- package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
- package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
- package/dist/Viewer3D-CJJJgkuv.js +0 -60
- package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
- package/src/components/SecondaryPane.vue +0 -447
- package/src/modals/ShareModal.vue +0 -139
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FilePane — pane:p1 THE pane. One component, rendered once per half of the
|
|
4
|
+
* window.
|
|
5
|
+
*
|
|
6
|
+
* ⚠⚠ WHY THIS FILE EXISTS, and why adding the missing controls to the old
|
|
7
|
+
* `SecondaryPane` would have been the wrong fix for the second time.
|
|
8
|
+
*
|
|
9
|
+
* The split view used to be two IMPLEMENTATIONS of a pane: `.fe__primary`
|
|
10
|
+
* inside `FileExplorer.vue` on the left, `SecondaryPane.vue` on the right.
|
|
11
|
+
* They shared the three view components and nothing else, so every control
|
|
12
|
+
* that hangs off a listing had to be written twice or it existed only on the
|
|
13
|
+
* left. It was written twice exactly once — the right-click menu, in July
|
|
14
|
+
* 2026, after the owner reported the two halves offering different actions —
|
|
15
|
+
* and the underlying shape was left alone. Two months later the right half was
|
|
16
|
+
* missing six more things: the breadcrumb was a private 28-line computed
|
|
17
|
+
* instead of `Breadcrumb.vue`, and there was no filter row, no view switcher,
|
|
18
|
+
* no sort control, no per-folder column menu and no selection bar at all.
|
|
19
|
+
*
|
|
20
|
+
* So the rule this file enforces is not "keep them in sync". It is that there
|
|
21
|
+
* is only one of them:
|
|
22
|
+
*
|
|
23
|
+
* a feature added to a pane cannot miss the other pane,
|
|
24
|
+
* because there is only one pane.
|
|
25
|
+
*
|
|
26
|
+
* ⚠ THE THREE DIFFERENCES THAT ARE REAL. Everything else is identical by
|
|
27
|
+
* construction:
|
|
28
|
+
*
|
|
29
|
+
* 1. **Which pane has the keyboard** — `focused`, drawn as the accent line
|
|
30
|
+
* (`.fe-pane--focus`). It is the only signal of where a shortcut lands,
|
|
31
|
+
* so it is a prop and never an internal guess.
|
|
32
|
+
* 2. **Where the listing came from** — the main pane's rows are produced by
|
|
33
|
+
* the explorer's own loader, which also serves search, the trash, the
|
|
34
|
+
* panel's virtual views and encrypted folders; the split pane asks the
|
|
35
|
+
* backend for one folder. Expressed as `selfDriven`: false = the host
|
|
36
|
+
* hands over `rows` / `loading` / `error`, true = this pane runs
|
|
37
|
+
* `loadFolder()` below. Nothing else in the component branches on it.
|
|
38
|
+
* 3. **Whether it can be closed** — `closable`. A window always has a main
|
|
39
|
+
* pane; the second one is the one you opened.
|
|
40
|
+
*
|
|
41
|
+
* ⚠ WHAT STAYS WITH THE HOST, and why none of it is pane state: the API
|
|
42
|
+
* client, the clipboard, the context menu and `dispatchItemAction`, uploads,
|
|
43
|
+
* quick look, the inspector, the onboarding tour's targets and the tab strip.
|
|
44
|
+
* Those are properties of the WINDOW — there is one clipboard and one context
|
|
45
|
+
* menu no matter how many panes are open — so a pane that owned any of them
|
|
46
|
+
* would be a second window.
|
|
47
|
+
*
|
|
48
|
+
* ⚠ Selection is HOST-HELD and passed in, which is not a compromise: the
|
|
49
|
+
* explorer routes cut/copy/paste, the inspector, the toolbar's count and every
|
|
50
|
+
* keyboard shortcut through "the active pane's selection", so it needs to hold
|
|
51
|
+
* both. It holds two instances of ONE composable (`useSelection`), which is
|
|
52
|
+
* the same single-definition rule one level up. Same for the filters
|
|
53
|
+
* (`lib/fileFilters`) and the sort (`lib/sortOrder`).
|
|
54
|
+
*
|
|
55
|
+
* ⚠⚠ THE SORT IS PER PANE and this component is what makes it so. It creates
|
|
56
|
+
* its own `SortStore` and `provide()`s it, so the filter row's sort control
|
|
57
|
+
* and the list view's column headers inside THIS pane drive THIS pane's order
|
|
58
|
+
* — measured 2026-09-13: with the old module-level singleton, choosing
|
|
59
|
+
* "Size ↓" in the right pane silently re-ordered the left one. The main pane
|
|
60
|
+
* keeps the default store, because the explorer's per-folder view memory reads
|
|
61
|
+
* and writes exactly that state.
|
|
62
|
+
*
|
|
63
|
+
* Unscoped styles (`fe-pane*`, and the `fe__primary` / `fe-subhead` / `fe__body`
|
|
64
|
+
* classes the layout already owns) — webcomponent data-v rule.
|
|
65
|
+
*/
|
|
66
|
+
import { computed, provide, ref, watch } from 'vue';
|
|
67
|
+
|
|
68
|
+
import type { FileApi } from '../composables/useFileApi';
|
|
69
|
+
import type { FileNode, ViewMode } from '../types/FileNode';
|
|
70
|
+
import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
|
|
71
|
+
import { useLocale } from '../composables/useLocale';
|
|
72
|
+
import {
|
|
73
|
+
filterListing,
|
|
74
|
+
injectTrashRow,
|
|
75
|
+
hydrateTrashRow,
|
|
76
|
+
isVirtualViewPath,
|
|
77
|
+
} from '../lib/listing';
|
|
78
|
+
import { iconFamilyFor } from '../lib/fileIcons';
|
|
79
|
+
import { applyFilters, filtersActive, type DriveFilters } from '../lib/fileFilters';
|
|
80
|
+
import {
|
|
81
|
+
SORT_STORE_KEY,
|
|
82
|
+
createSortStore,
|
|
83
|
+
useSortStore,
|
|
84
|
+
type ListingOrder,
|
|
85
|
+
type SortStore,
|
|
86
|
+
} from '../lib/sortOrder';
|
|
87
|
+
import { hasInternalDrag, internalDragItems, internalDragOrigin } from '../lib/dragOut';
|
|
88
|
+
import Breadcrumb from './Breadcrumb.vue';
|
|
89
|
+
import FilterBar from './FilterBar.vue';
|
|
90
|
+
import ViewSwitcher from './ViewSwitcher.vue';
|
|
91
|
+
import ListView from './ListView.vue';
|
|
92
|
+
import GridView from './GridView.vue';
|
|
93
|
+
import GalleryView from './GalleryView.vue';
|
|
94
|
+
|
|
95
|
+
/** The same literals the view components hardcode for the internal DnD
|
|
96
|
+
* channel; `-src` carries the ORIGIN DIRECTORY so the host can move across
|
|
97
|
+
* panes and undo it afterwards. */
|
|
98
|
+
const FE_DND_MIME = 'application/x-brf-files';
|
|
99
|
+
const FE_DND_SRC_MIME = 'application/x-brf-files-src';
|
|
100
|
+
|
|
101
|
+
export type PaneId = 'main' | 'split';
|
|
102
|
+
|
|
103
|
+
const props = withDefaults(
|
|
104
|
+
defineProps<{
|
|
105
|
+
/** Which half this is. Also the `data-pane` hook every measurement uses. */
|
|
106
|
+
paneId: PaneId;
|
|
107
|
+
api: FileApi;
|
|
108
|
+
locale: LocaleCode;
|
|
109
|
+
/** Resolved theme — forwarded to the children whose popovers are teleported
|
|
110
|
+
* out of `.fe`, where the token scope no longer reaches them. */
|
|
111
|
+
theme?: ThemeMode;
|
|
112
|
+
/** This pane owns the keyboard. The accent line, and nothing else. */
|
|
113
|
+
focused?: boolean;
|
|
114
|
+
/** Offer the × (the split pane). */
|
|
115
|
+
closable?: boolean;
|
|
116
|
+
|
|
117
|
+
// ── addressing ────────────────────────────────────────────────────
|
|
118
|
+
/** The pane's location, in the host's user-path form. With
|
|
119
|
+
* `selfDriven` it is the OPENING location and this pane moves on from
|
|
120
|
+
* there; otherwise it is the address, full stop. */
|
|
121
|
+
path: string;
|
|
122
|
+
/** true → this pane fetches its own folder (see difference 2 above). */
|
|
123
|
+
selfDriven?: boolean;
|
|
124
|
+
/** user path → wire `<adapter>://<rel>`. */
|
|
125
|
+
qualify: (p: string) => string;
|
|
126
|
+
/** wire → user path (multi-storage aware). */
|
|
127
|
+
toUser: (wire: string) => string;
|
|
128
|
+
/** rootFloor clamp — keeps the pane inside the confine. */
|
|
129
|
+
clamp: (p: string) => string;
|
|
130
|
+
/** Override for the storage crumb's label. ⚠ Optional, and normally
|
|
131
|
+
* omitted: the pane derives it from its OWN wire path (`paneAdapter`), so
|
|
132
|
+
* two panes sitting in two different storages each name their own. The
|
|
133
|
+
* old right-hand pane was handed the window's label and printed "/" for a
|
|
134
|
+
* folder called `thumbfix` — measured 2026-09-13, and the exact shape of
|
|
135
|
+
* bug a second implementation produces. */
|
|
136
|
+
rootLabel?: string;
|
|
137
|
+
/** The confine in WIRE form, for `Breadcrumb`'s own floor handling. */
|
|
138
|
+
rootPath?: string;
|
|
139
|
+
/** rootFloor in user-path form ('' when unconfined). */
|
|
140
|
+
floor?: string;
|
|
141
|
+
/** Multi-storage mode: '' is the virtual drives root (no fetch). */
|
|
142
|
+
multiRoot?: boolean;
|
|
143
|
+
/** Synthesized storage rows for the virtual drives root. */
|
|
144
|
+
virtualRows?: () => FileNode[];
|
|
145
|
+
/** Mirror the virtual `.trash` row at a storage root. Defaults on. */
|
|
146
|
+
trashVisible?: boolean;
|
|
147
|
+
/** The navigation panel is already offering Trash, so no pane draws the
|
|
148
|
+
* virtual row. Passed down rather than worked out here: the panel is a
|
|
149
|
+
* sibling of BOTH panes, and a pane that guessed would list a row the
|
|
150
|
+
* other one does not. */
|
|
151
|
+
navOffersTrash?: boolean;
|
|
152
|
+
|
|
153
|
+
// ── the rows, when the host drives them ───────────────────────────
|
|
154
|
+
rows?: FileNode[];
|
|
155
|
+
loading?: boolean;
|
|
156
|
+
error?: string;
|
|
157
|
+
/** Where these rows got their order (`lib/sortOrder`). A ranked answer
|
|
158
|
+
* keeps the server's order and closes every sort control over it. */
|
|
159
|
+
order?: ListingOrder;
|
|
160
|
+
|
|
161
|
+
// ── chrome ────────────────────────────────────────────────────────
|
|
162
|
+
viewMode: ViewMode;
|
|
163
|
+
viewModes?: ViewMode[];
|
|
164
|
+
/** Draw the address row. Off for a host that supplies its own heading. */
|
|
165
|
+
showCrumbs?: boolean;
|
|
166
|
+
/** Draw the filter row (and with it the sort control). */
|
|
167
|
+
showFilterBar?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* surucu:d1-scope — `find` reduces the filter row to its name box (see
|
|
170
|
+
* `FilterBar`'s own note). The host asks for it where the BODY is not a
|
|
171
|
+
* listing of files at all — Home. It is NOT asked for at the multi-storage
|
|
172
|
+
* root: a pane knows perfectly well when it is showing the drive list
|
|
173
|
+
* (`atVirtualRoot`), and making the host say so for the main pane would
|
|
174
|
+
* leave the split pane's own drive list with four chips that narrow nothing.
|
|
175
|
+
*/
|
|
176
|
+
filterMode?: 'full' | 'find';
|
|
177
|
+
/** What the name box says it narrows, already translated. Ignored at the
|
|
178
|
+
* drive root, which names itself. */
|
|
179
|
+
findLabel?: string;
|
|
180
|
+
/** Draw the view switcher in the address row. */
|
|
181
|
+
showViewSwitcher?: boolean;
|
|
182
|
+
/** Override for what the last crumb's "Subfolders" chevron lists. ⚠
|
|
183
|
+
* Optional, and normally omitted: the pane already HOLDS the folder's
|
|
184
|
+
* listing, so it answers "what is inside this folder" from its own rows.
|
|
185
|
+
* Passing it from the host is how only one pane ended up with the
|
|
186
|
+
* chevron. */
|
|
187
|
+
crumbSubfolders?: { label: string; adapterPath: string }[];
|
|
188
|
+
/** Per-folder memory key, for the column menu's two folder rows. */
|
|
189
|
+
folderKey?: string;
|
|
190
|
+
/** Print each row's parent folder (a search, a cross-folder view). */
|
|
191
|
+
showParentPath?: boolean;
|
|
192
|
+
/** Paths that were cut — dimmed, and never dragged. */
|
|
193
|
+
clipped?: Set<string>;
|
|
194
|
+
/** A second narrowing composed AFTER this pane's own filter row (the
|
|
195
|
+
* advanced-search dialog's). Null when there is none. */
|
|
196
|
+
extraFilters?: DriveFilters | null;
|
|
197
|
+
canWrite?: boolean;
|
|
198
|
+
canPaste?: boolean;
|
|
199
|
+
|
|
200
|
+
// ── the listing's own state, held by the host ─────────────────────
|
|
201
|
+
selected: Set<string>;
|
|
202
|
+
filters: DriveFilters;
|
|
203
|
+
|
|
204
|
+
// ── forwarded to the view components ──────────────────────────────
|
|
205
|
+
thumbSrc?: (n: FileNode) => string | null;
|
|
206
|
+
keepBadgeFor?: (n: FileNode) => 'kept' | 'syncing' | 'cloud' | 'partial' | null;
|
|
207
|
+
starredIds?: Set<number>;
|
|
208
|
+
starEnabled?: boolean;
|
|
209
|
+
apiBase?: string;
|
|
210
|
+
authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
211
|
+
authCredentials?: RequestCredentials;
|
|
212
|
+
/** No reading file bytes for a card preview inside an encrypted folder. */
|
|
213
|
+
e2eActive?: boolean;
|
|
214
|
+
|
|
215
|
+
/** The host is drawing the body itself (Home, a dead deep link, the
|
|
216
|
+
* encrypted lock screen) — those are WINDOW states with no listing
|
|
217
|
+
* behind them, so the pane's own state chain must not also run. */
|
|
218
|
+
bodyOverride?: boolean;
|
|
219
|
+
}>(),
|
|
220
|
+
{
|
|
221
|
+
showCrumbs: true,
|
|
222
|
+
showFilterBar: true,
|
|
223
|
+
showViewSwitcher: true,
|
|
224
|
+
trashVisible: true,
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const emit = defineEmits<{
|
|
229
|
+
/** This pane was touched — it now owns the keyboard. */
|
|
230
|
+
(e: 'activate'): void;
|
|
231
|
+
(e: 'close'): void;
|
|
232
|
+
/** The pane's location changed (self-driven) or was asked to change. */
|
|
233
|
+
(e: 'navigate', path: string): void;
|
|
234
|
+
/** A row was double-clicked / activated. */
|
|
235
|
+
(e: 'open', node: FileNode): void;
|
|
236
|
+
/** The virtual `.trash` row was opened — the trash view carries the restore
|
|
237
|
+
* actions and belongs to the main pane, so the host decides. */
|
|
238
|
+
(e: 'open-trash'): void;
|
|
239
|
+
(e: 'click-row', node: FileNode, mod: { ctrl: boolean; shift: boolean; touch?: boolean }): void;
|
|
240
|
+
/** Right-click. `null` = empty space (nothing selected → "Paste" only). */
|
|
241
|
+
(e: 'context', node: FileNode | null, ev: MouseEvent): void;
|
|
242
|
+
(e: 'clear-selection'): void;
|
|
243
|
+
(e: 'display-order', nodes: FileNode[]): void;
|
|
244
|
+
(e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
|
|
245
|
+
(e: 'item-drop-into', target: FileNode, ev: DragEvent): void;
|
|
246
|
+
/** A drop on the pane background / a crumb: the host owns move-vs-copy. */
|
|
247
|
+
(e: 'transfer', p: { sources: string[]; targetWire: string; originWire?: string }): void;
|
|
248
|
+
(e: 'update:viewMode', v: ViewMode): void;
|
|
249
|
+
(e: 'update:filters', v: DriveFilters): void;
|
|
250
|
+
(e: 'crumb-context', payload: { x: number; y: number; adapterPath: string; label: string }): void;
|
|
251
|
+
(e: 'copy-path', adapterPath: string): void;
|
|
252
|
+
(e: 'crumb-drop', adapterPath: string, ev: DragEvent): void;
|
|
253
|
+
(e: 'new-folder'): void;
|
|
254
|
+
(e: 'upload'): void;
|
|
255
|
+
(e: 'paste'): void;
|
|
256
|
+
(e: 'select-all'): void;
|
|
257
|
+
(e: 'clear-filters'): void;
|
|
258
|
+
(e: 'star-change', node: FileNode, value: boolean): void;
|
|
259
|
+
/** The listing failed and the person asked for it again. */
|
|
260
|
+
(e: 'retry'): void;
|
|
261
|
+
}>();
|
|
262
|
+
|
|
263
|
+
const { t } = useLocale(() => props.locale);
|
|
264
|
+
|
|
265
|
+
/* ── the pane's sort ───────────────────────────────────────────────────
|
|
266
|
+
* ⚠⚠ The main pane keeps the DEFAULT store on purpose. The explorer's
|
|
267
|
+
* per-folder view memory (`lib/viewPrefs`) restores and records through the
|
|
268
|
+
* module-level `applySort` / `activeSortKey`, i.e. through that store; handing
|
|
269
|
+
* the main pane a fresh one would leave the memory writing to a piece of state
|
|
270
|
+
* nothing renders. The split pane gets its own, which is what makes two sort
|
|
271
|
+
* controls two sorts. */
|
|
272
|
+
const sort: SortStore = props.paneId === 'main' ? useSortStore() : createSortStore();
|
|
273
|
+
provide(SORT_STORE_KEY, sort);
|
|
274
|
+
|
|
275
|
+
/* ── self-driven listing (difference 2) ──────────────────────────────── */
|
|
276
|
+
|
|
277
|
+
const ownPath = ref<string>('');
|
|
278
|
+
const ownRows = ref<FileNode[]>([]);
|
|
279
|
+
const ownLoading = ref(false);
|
|
280
|
+
const ownError = ref('');
|
|
281
|
+
/** koru:k1 — the folder's RBAC level as the last `index` reported it. Kept
|
|
282
|
+
* because the details panel asks the FOCUSED pane for it, and until now the
|
|
283
|
+
* split pane had no answer to give. */
|
|
284
|
+
const ownPerm = ref('');
|
|
285
|
+
|
|
286
|
+
function isStorageRow(n: FileNode): boolean {
|
|
287
|
+
return iconFamilyFor(n) === 'storage';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** THE location this pane is showing, whichever side is driving. */
|
|
291
|
+
const panePath = computed(() => (props.selfDriven ? ownPath.value : (props.path ?? '')));
|
|
292
|
+
const paneRows = computed<FileNode[]>(() =>
|
|
293
|
+
props.selfDriven ? ownRows.value : (props.rows ?? []),
|
|
294
|
+
);
|
|
295
|
+
const paneLoading = computed(() => (props.selfDriven ? ownLoading.value : !!props.loading));
|
|
296
|
+
const paneError = computed(() => (props.selfDriven ? ownError.value : (props.error ?? '')));
|
|
297
|
+
|
|
298
|
+
const atVirtualRoot = computed(
|
|
299
|
+
() => !!props.multiRoot && !(panePath.value ?? '').replace(/^\/+|\/+$/g, ''),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* One folder, through the SAME `api.index` + `lib/listing` helpers the host's
|
|
304
|
+
* own loader uses — so the two panes cannot end up listing different rows for
|
|
305
|
+
* the same folder (the internal-entry filter and the virtual `.trash` row are
|
|
306
|
+
* both shared functions, not copies).
|
|
307
|
+
*/
|
|
308
|
+
async function loadFolder(target?: string): Promise<void> {
|
|
309
|
+
const requested = props.clamp(target ?? ownPath.value ?? '');
|
|
310
|
+
ownLoading.value = true;
|
|
311
|
+
ownError.value = '';
|
|
312
|
+
try {
|
|
313
|
+
// Multi-storage virtual root: synthesize the drives list, no backend call.
|
|
314
|
+
if (props.multiRoot && !requested) {
|
|
315
|
+
ownRows.value = props.virtualRows ? props.virtualRows() : [];
|
|
316
|
+
ownPerm.value = ''; /* the drive list is not a folder — no level to carry */
|
|
317
|
+
ownPath.value = '';
|
|
318
|
+
emit('navigate', '');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const resp = await props.api.index(props.qualify(requested));
|
|
322
|
+
ownPerm.value = ((resp as { perm?: unknown }).perm as string) || '';
|
|
323
|
+
ownRows.value = filterListing(resp.files);
|
|
324
|
+
if (
|
|
325
|
+
injectTrashRow(ownRows.value, resp.adapter, resp.dirname, props.trashVisible !== false, {
|
|
326
|
+
navOffersTrash: props.navOffersTrash,
|
|
327
|
+
})
|
|
328
|
+
) {
|
|
329
|
+
void hydrateTrashRow(ownRows.value, resp.adapter, props.api);
|
|
330
|
+
}
|
|
331
|
+
ownPath.value = props.toUser(resp.dirname);
|
|
332
|
+
emit('navigate', ownPath.value);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
ownError.value = err instanceof Error ? err.message : String(err);
|
|
335
|
+
} finally {
|
|
336
|
+
ownLoading.value = false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (props.selfDriven) void loadFolder(props.path ?? '');
|
|
341
|
+
|
|
342
|
+
/** Navigate THIS pane. Self-driven panes fetch; a host-driven pane asks its
|
|
343
|
+
* host, which is the only thing that knows how to leave a search or a
|
|
344
|
+
* virtual view on the way. */
|
|
345
|
+
function goTo(target: string) {
|
|
346
|
+
emit('activate');
|
|
347
|
+
if (props.selfDriven) void loadFolder(target);
|
|
348
|
+
else emit('navigate', target);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* ── the address row ───────────────────────────────────────────────────
|
|
352
|
+
* `Breadcrumb.vue`, not a private crumb computed. It already knows about the
|
|
353
|
+
* multi-storage "/" crumb, the confined root, the virtual segments' labels,
|
|
354
|
+
* the ✏ path editor and drops onto a crumb — all of which the old right-hand
|
|
355
|
+
* pane either lacked or re-derived. */
|
|
356
|
+
/**
|
|
357
|
+
* ⚠⚠ A VIRTUAL VIEW IS NOT QUALIFIED, and this line is the whole of bug #1.
|
|
358
|
+
*
|
|
359
|
+
* `qualify()` turns a user path into `<adapter>://<rel>` for the backend, and
|
|
360
|
+
* in multi-storage mode it does that by splitting the FIRST SEGMENT off as the
|
|
361
|
+
* storage. Handed `.starred` it answers `.starred://` — perfectly reasonable
|
|
362
|
+
* for `thumbfix`, catastrophic for a sentinel: from there the sentinel is the
|
|
363
|
+
* ADAPTER, `Breadcrumb` draws it as the storage crumb, and a storage crumb is
|
|
364
|
+
* never run through `virtualSegmentLabel`. The resolver was reached on every
|
|
365
|
+
* render and could not possibly match. (Before tonight the host handed
|
|
366
|
+
* `Breadcrumb` its own `dirname`/`adapter` refs, which the virtual views set to
|
|
367
|
+
* `.starred` / `''` — unqualified — so this never arose until each pane began
|
|
368
|
+
* deriving its own.)
|
|
369
|
+
*
|
|
370
|
+
* A view has no storage: `.recent` spans every one of them. So the wire form IS
|
|
371
|
+
* the sentinel, `paneAdapter` below comes out empty, and the segment reaches
|
|
372
|
+
* the resolver as a segment.
|
|
373
|
+
*/
|
|
374
|
+
const paneWire = computed(() =>
|
|
375
|
+
isVirtualViewPath(panePath.value) ? panePath.value : props.qualify(panePath.value),
|
|
376
|
+
);
|
|
377
|
+
/** The adapter THIS pane is inside. In multi-storage mode the two panes can
|
|
378
|
+
* be in different storages, so it is read off the pane's own wire path
|
|
379
|
+
* rather than taken from the window. */
|
|
380
|
+
const paneAdapter = computed(() => {
|
|
381
|
+
const w = paneWire.value;
|
|
382
|
+
const i = w.indexOf('://');
|
|
383
|
+
return i === -1 ? '' : w.slice(0, i);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
function onCrumbNavigate(adapterPath: string) {
|
|
387
|
+
emit('activate');
|
|
388
|
+
if (!props.selfDriven) {
|
|
389
|
+
emit('navigate', adapterPath);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
// Multi-storage emits '' for the global "/" crumb — the virtual drives root.
|
|
393
|
+
if (props.multiRoot && !adapterPath) {
|
|
394
|
+
void loadFolder('');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
void loadFolder(props.toUser(adapterPath));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* ── the rows this pane draws ──────────────────────────────────────────
|
|
401
|
+
* The filter row's narrowing, then the host's extra one (the advanced-search
|
|
402
|
+
* dialog composes with the chips rather than replacing them), then the order.
|
|
403
|
+
*
|
|
404
|
+
* ⚠ Sorted HERE, at the pane, and not inside each view: "sorted by size" is a
|
|
405
|
+
* fact about the LISTING, and three components each sorting for themselves is
|
|
406
|
+
* how the grid and the list drifted apart in the first place (filex #67). */
|
|
407
|
+
const displayFiles = computed<FileNode[]>(() => {
|
|
408
|
+
const base = applyFilters(paneRows.value, props.filters);
|
|
409
|
+
return sort.sortListing(
|
|
410
|
+
props.extraFilters ? applyFilters(base, props.extraFilters) : base,
|
|
411
|
+
props.order ?? 'sort',
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
const filtersOn = computed(() => filtersActive(props.filters) || !!props.extraFilters);
|
|
415
|
+
|
|
416
|
+
/* surucu:d1-scope — the filter row's shape, decided HERE rather than by the
|
|
417
|
+
* host, for the reason the `filterMode` prop documents: the drive list is a
|
|
418
|
+
* state a pane is in, and both panes can be in it independently. */
|
|
419
|
+
const paneFilterMode = computed<'full' | 'find'>(() =>
|
|
420
|
+
atVirtualRoot.value ? 'find' : (props.filterMode ?? 'full'),
|
|
421
|
+
);
|
|
422
|
+
const paneFindLabel = computed(() =>
|
|
423
|
+
atVirtualRoot.value ? t('filter.find.storages') : (props.findLabel ?? ''),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
/* What the last crumb's chevron lists.
|
|
427
|
+
*
|
|
428
|
+
* ⚠ Off `paneRows`, not `displayFiles`: the chevron answers "what is inside
|
|
429
|
+
* this folder", and a filter row that is hiding half the listing must not also
|
|
430
|
+
* decide where you are allowed to navigate — a folder you cannot see is still a
|
|
431
|
+
* folder you can walk into.
|
|
432
|
+
* ⚠ An empty array rather than undefined when there are no subfolders: the
|
|
433
|
+
* prop's presence is what draws the control, and a control that vanishes in
|
|
434
|
+
* leaf folders is one nobody learns to look for. */
|
|
435
|
+
const paneSubfolders = computed(() =>
|
|
436
|
+
paneRows.value
|
|
437
|
+
.filter((f) => f.type === 'dir')
|
|
438
|
+
.map((f) => ({ label: f.basename, adapterPath: f.path })),
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
/* ── selection + activation ────────────────────────────────────────────
|
|
442
|
+
* The pane never mutates the selection; it reports the gesture and the host's
|
|
443
|
+
* `useSelection` instance for this pane answers. One set of Ctrl/Shift
|
|
444
|
+
* semantics for both panes — the old right pane had a simplified copy in
|
|
445
|
+
* which Shift behaved as Ctrl. */
|
|
446
|
+
function onViewClick(n: FileNode, mod: { ctrl: boolean; shift: boolean; touch?: boolean }) {
|
|
447
|
+
/* issue #26 — a finger has no double-click, so a TAP is the open gesture:
|
|
448
|
+
* with nothing selected it opens what it lands on, exactly as a double-click
|
|
449
|
+
* would. Once something is selected (a long press selects, via its menu) a
|
|
450
|
+
* tap adds to or removes from the selection, so picking several files still
|
|
451
|
+
* works. A mouse keeps click-to-select; see composables/useRowTouch. */
|
|
452
|
+
if (mod.touch && !mod.ctrl && !mod.shift) {
|
|
453
|
+
if (props.selected.size === 0) {
|
|
454
|
+
onRowOpen(n);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
mod = { ...mod, ctrl: true };
|
|
458
|
+
}
|
|
459
|
+
emit('activate');
|
|
460
|
+
emit('click-row', n, mod);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function onViewContext(n: FileNode, ev: MouseEvent) {
|
|
464
|
+
ev.preventDefault();
|
|
465
|
+
ev.stopPropagation();
|
|
466
|
+
emit('activate');
|
|
467
|
+
emit('context', n, ev);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Right-click on genuinely empty space: a selection-less menu ("Paste"). The
|
|
471
|
+
* row menu calls stopPropagation, so this only fires on the background. */
|
|
472
|
+
function onBgContext(ev: MouseEvent) {
|
|
473
|
+
ev.preventDefault();
|
|
474
|
+
ev.stopPropagation();
|
|
475
|
+
emit('activate');
|
|
476
|
+
emit('clear-selection');
|
|
477
|
+
emit('context', null, ev);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function onBgClick() {
|
|
481
|
+
emit('clear-selection');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function onRowOpen(n: FileNode) {
|
|
485
|
+
emit('activate');
|
|
486
|
+
if (n.basename === '.trash') {
|
|
487
|
+
emit('open-trash');
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (n.type === 'dir' && props.selfDriven) {
|
|
491
|
+
void loadFolder(isStorageRow(n) ? n.path : props.toUser(n.path));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
emit('open', n);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/* ── drag source ───────────────────────────────────────────────────────
|
|
498
|
+
* The pane writes the standard payload (the internal MIME, the ORIGIN
|
|
499
|
+
* directory that makes a cross-pane move undoable, and a plain-text
|
|
500
|
+
* fallback) and then hands the event on. The host can still take the drag
|
|
501
|
+
* over — the desktop shell turns it into a real OS drag — which is why the
|
|
502
|
+
* emit comes last and the host is free to `preventDefault`. */
|
|
503
|
+
function onRowDragStart(n: FileNode, ev: DragEvent) {
|
|
504
|
+
if (!ev.dataTransfer) return;
|
|
505
|
+
if (isStorageRow(n) || atVirtualRoot.value || n.basename === '.trash') {
|
|
506
|
+
ev.preventDefault();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const items = paneRows.value
|
|
510
|
+
.filter((f) => props.selected.has(f.path) && !isStorageRow(f))
|
|
511
|
+
.filter((f) => !props.clipped?.has(f.path))
|
|
512
|
+
.map((f) => ({ path: f.path, basename: f.basename, type: f.type }));
|
|
513
|
+
const payload = items.length > 0 ? items : [{ path: n.path, basename: n.basename, type: n.type }];
|
|
514
|
+
ev.dataTransfer.setData(FE_DND_MIME, JSON.stringify(payload));
|
|
515
|
+
ev.dataTransfer.setData(FE_DND_SRC_MIME, paneWire.value);
|
|
516
|
+
ev.dataTransfer.setData('text/plain', payload.map((i) => i.path).join('\n'));
|
|
517
|
+
ev.dataTransfer.effectAllowed = 'move';
|
|
518
|
+
emit('item-drag-start', n, ev);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* ── drop target (dir rows + the pane background) ─────────────────────── */
|
|
522
|
+
|
|
523
|
+
const dropBg = ref(false);
|
|
524
|
+
|
|
525
|
+
function acceptDrag(ev: DragEvent): boolean {
|
|
526
|
+
return hasInternalDrag(ev);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function handleDropPayload(ev: DragEvent, targetWire: string) {
|
|
530
|
+
const items = internalDragItems(ev);
|
|
531
|
+
if (!items || !targetWire) return;
|
|
532
|
+
const origin = internalDragOrigin(ev);
|
|
533
|
+
const sources = items
|
|
534
|
+
.map((i) => i.path)
|
|
535
|
+
.filter((p) => p && p !== targetWire && !targetWire.startsWith(p + '/'));
|
|
536
|
+
if (sources.length === 0) return;
|
|
537
|
+
ev.preventDefault();
|
|
538
|
+
ev.stopPropagation();
|
|
539
|
+
emit('transfer', { sources, targetWire, originWire: origin });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function onViewDropInto(target: FileNode, ev: DragEvent) {
|
|
543
|
+
dropBg.value = false;
|
|
544
|
+
if (target.type !== 'dir' || isStorageRow(target)) return;
|
|
545
|
+
if (!acceptDrag(ev)) return;
|
|
546
|
+
emit('item-drop-into', target, ev);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function onBgDragOver(ev: DragEvent) {
|
|
550
|
+
if (!acceptDrag(ev) || atVirtualRoot.value) return;
|
|
551
|
+
ev.preventDefault();
|
|
552
|
+
ev.stopPropagation();
|
|
553
|
+
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
|
|
554
|
+
dropBg.value = true;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function onBgDragLeave() {
|
|
558
|
+
dropBg.value = false;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function onBgDrop(ev: DragEvent) {
|
|
562
|
+
dropBg.value = false;
|
|
563
|
+
if (!acceptDrag(ev) || atVirtualRoot.value) return;
|
|
564
|
+
handleDropPayload(ev, paneWire.value);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* ── the host's handle on this pane ────────────────────────────────────
|
|
568
|
+
* Keyboard routing, cut/copy/paste and the context menu all act on "the
|
|
569
|
+
* active pane"; these are the four things the host cannot work out from its
|
|
570
|
+
* own state. Both panes expose the SAME handle, so the routing in the host is
|
|
571
|
+
* one branch on an id rather than two code paths. */
|
|
572
|
+
function reload(): Promise<void> {
|
|
573
|
+
return props.selfDriven ? loadFolder() : Promise.resolve();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function goUp() {
|
|
577
|
+
const cur = (panePath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
578
|
+
const floor = (props.floor || '').replace(/^\/+|\/+$/g, '');
|
|
579
|
+
if (!cur || cur === floor) return;
|
|
580
|
+
const idx = cur.lastIndexOf('/');
|
|
581
|
+
goTo(idx === -1 ? '' : cur.slice(0, idx));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function getPath(): string {
|
|
585
|
+
return panePath.value;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** What is on screen, in the order it is drawn — the list a Select-all or a
|
|
589
|
+
* Shift-range has to be arithmetic over. */
|
|
590
|
+
function visibleNodes(): FileNode[] {
|
|
591
|
+
return displayFiles.value.filter((n) => !isStorageRow(n));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* How many rows this pane's folder holds — BEFORE the filter row narrows them,
|
|
596
|
+
* which is what "12 items" in the details panel means (`visibleNodes()` is the
|
|
597
|
+
* other question: what is drawn right now).
|
|
598
|
+
*/
|
|
599
|
+
function rowCount(): number {
|
|
600
|
+
return paneRows.value.length;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The paths this pane's folder HOLDS — unfiltered, and that is the point.
|
|
605
|
+
*
|
|
606
|
+
* ⚠ Not `visibleNodes()`. The one caller asks "does this row still exist?", and
|
|
607
|
+
* `visibleNodes()` answers "is this row currently drawn?" — two different
|
|
608
|
+
* questions that only agree while the filter row is empty. Answering the first
|
|
609
|
+
* with the second would make typing in the filter box look exactly like a
|
|
610
|
+
* deletion to whoever is asking.
|
|
611
|
+
*/
|
|
612
|
+
function rowPaths(): string[] {
|
|
613
|
+
return paneRows.value.map((n) => n.path);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* koru:k1 — the RBAC level of the folder this pane is in, '' when ACL is not
|
|
618
|
+
* enforced (or when the host drives the rows: it answered the request, so it
|
|
619
|
+
* is holding the `perm` this pane never saw).
|
|
620
|
+
*/
|
|
621
|
+
function dirPerm(): string {
|
|
622
|
+
return props.selfDriven ? ownPerm.value : '';
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
defineExpose({ reload, goUp, getPath, visibleNodes, loadFolder, rowCount, rowPaths, dirPerm });
|
|
626
|
+
|
|
627
|
+
/* A host-driven pane whose address moved (a tab switch, a search) has to drop
|
|
628
|
+
* any drop-highlight it was showing; nothing else here is stateful across a
|
|
629
|
+
* navigation. */
|
|
630
|
+
watch(panePath, () => {
|
|
631
|
+
dropBg.value = false;
|
|
632
|
+
});
|
|
633
|
+
</script>
|
|
634
|
+
|
|
635
|
+
<template>
|
|
636
|
+
<section
|
|
637
|
+
class="fe__primary fe-pane"
|
|
638
|
+
:class="{
|
|
639
|
+
'fe-pane--focus': focused,
|
|
640
|
+
'fe-pane--split': paneId === 'split',
|
|
641
|
+
'fe-pane--dropover': dropBg,
|
|
642
|
+
}"
|
|
643
|
+
:data-pane="paneId"
|
|
644
|
+
:data-testid="`pane-${paneId}`"
|
|
645
|
+
role="region"
|
|
646
|
+
:aria-label="paneId === 'split' ? t('split.pane') : t('toolbar.view_label')"
|
|
647
|
+
@pointerdown.capture="emit('activate')"
|
|
648
|
+
>
|
|
649
|
+
<!-- The address row. `Breadcrumb.vue` in BOTH panes — the right-hand half
|
|
650
|
+
used to draw a private crumb strip that knew nothing about the
|
|
651
|
+
multi-storage root, the confine, the ✏ editor or crumb drops.
|
|
652
|
+
|
|
653
|
+
⚠ Not drawn when it would be EMPTY. Home asks for no crumbs (its cards
|
|
654
|
+
come from every folder in every storage, so a trail would have to name
|
|
655
|
+
one) and no view switcher, and as of 2026-09-13 it supplies no heading
|
|
656
|
+
either — so without this guard the page opened with a 38px bordered
|
|
657
|
+
strip containing nothing, which reads as a rendering fault rather than
|
|
658
|
+
as a design. -->
|
|
659
|
+
<div v-if="showCrumbs || !!$slots.heading || showViewSwitcher || closable" class="fe-subhead">
|
|
660
|
+
<slot name="heading">
|
|
661
|
+
<Breadcrumb
|
|
662
|
+
v-if="showCrumbs"
|
|
663
|
+
:dirname="paneWire"
|
|
664
|
+
:adapter="paneAdapter"
|
|
665
|
+
:root-label="rootLabel || paneAdapter"
|
|
666
|
+
:locale="locale"
|
|
667
|
+
:multi-storage-root="multiRoot"
|
|
668
|
+
:root-path="rootPath"
|
|
669
|
+
:subfolders="crumbSubfolders ?? paneSubfolders"
|
|
670
|
+
@navigate="onCrumbNavigate"
|
|
671
|
+
@copy-path="(p: string) => emit('copy-path', p)"
|
|
672
|
+
@crumb-context="(p) => emit('crumb-context', p)"
|
|
673
|
+
@crumb-drop="(p: string, ev: DragEvent) => emit('crumb-drop', p, ev)"
|
|
674
|
+
/>
|
|
675
|
+
</slot>
|
|
676
|
+
<div class="fe-subhead__actions">
|
|
677
|
+
<ViewSwitcher
|
|
678
|
+
v-if="showViewSwitcher"
|
|
679
|
+
:view-mode="viewMode"
|
|
680
|
+
:locale="locale"
|
|
681
|
+
:modes="viewModes"
|
|
682
|
+
@update:view-mode="(v: ViewMode) => { emit('activate'); emit('update:viewMode', v); }"
|
|
683
|
+
/>
|
|
684
|
+
<button
|
|
685
|
+
v-if="closable"
|
|
686
|
+
type="button"
|
|
687
|
+
class="fe-btn fe-btn--icon-only fe-pane__close"
|
|
688
|
+
:title="t('split.close')"
|
|
689
|
+
:aria-label="t('split.close')"
|
|
690
|
+
data-testid="pane-close"
|
|
691
|
+
@click="emit('close')"
|
|
692
|
+
>
|
|
693
|
+
<svg
|
|
694
|
+
class="fe-ficon"
|
|
695
|
+
viewBox="0 0 24 24"
|
|
696
|
+
fill="none"
|
|
697
|
+
stroke="currentColor"
|
|
698
|
+
stroke-width="1.8"
|
|
699
|
+
stroke-linecap="round"
|
|
700
|
+
aria-hidden="true"
|
|
701
|
+
focusable="false"
|
|
702
|
+
>
|
|
703
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
704
|
+
</svg>
|
|
705
|
+
</button>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
|
|
709
|
+
<!-- Where the selection bar lands. Rendered by the pane, declaratively and
|
|
710
|
+
in the right place, because the row above it only exists after the
|
|
711
|
+
first tab is seeded — a node inserted by script would end up above it
|
|
712
|
+
for the rest of the session. The filter row hides behind
|
|
713
|
+
`.fe-selbar-slot.is-active ~ .fe-filterbar`. -->
|
|
714
|
+
<div class="fe-selbar-slot"></div>
|
|
715
|
+
|
|
716
|
+
<!-- ⚠ No `&& !atVirtualRoot` any more. The drive list KEEPS this row — it
|
|
717
|
+
is the one place a person can narrow a long list of storages by name —
|
|
718
|
+
but as the name box alone (`paneFilterMode`), because a drive answers
|
|
719
|
+
none of the four chips. -->
|
|
720
|
+
<FilterBar
|
|
721
|
+
v-if="showFilterBar"
|
|
722
|
+
:value="filters"
|
|
723
|
+
:locale="locale"
|
|
724
|
+
:theme="theme"
|
|
725
|
+
:mode="paneFilterMode"
|
|
726
|
+
:find-label="paneFindLabel"
|
|
727
|
+
:order="order"
|
|
728
|
+
:shown="displayFiles.length"
|
|
729
|
+
:total="paneRows.length"
|
|
730
|
+
:files="paneRows"
|
|
731
|
+
:can-paste="canPaste"
|
|
732
|
+
:can-write="canWrite"
|
|
733
|
+
@update:value="(v: DriveFilters) => { emit('activate'); emit('update:filters', v); }"
|
|
734
|
+
@new-folder="emit('activate'); emit('new-folder')"
|
|
735
|
+
@upload="emit('activate'); emit('upload')"
|
|
736
|
+
@paste="emit('activate'); emit('paste')"
|
|
737
|
+
@select-all="emit('activate'); emit('select-all')"
|
|
738
|
+
/>
|
|
739
|
+
|
|
740
|
+
<!-- Strips that belong to the WINDOW's state rather than to the listing
|
|
741
|
+
(live presence, the encrypted-folder banners, the trash banner). Only
|
|
742
|
+
the host can know about them, so it puts them here. -->
|
|
743
|
+
<slot name="banners"></slot>
|
|
744
|
+
|
|
745
|
+
<div
|
|
746
|
+
class="fe__body"
|
|
747
|
+
:class="{ 'is-dropover': dropBg }"
|
|
748
|
+
@click.self="onBgClick"
|
|
749
|
+
@contextmenu="onBgContext"
|
|
750
|
+
@dragover="onBgDragOver"
|
|
751
|
+
@dragleave="onBgDragLeave"
|
|
752
|
+
@drop="onBgDrop"
|
|
753
|
+
>
|
|
754
|
+
<!-- The host draws the body itself for the states that have no listing
|
|
755
|
+
behind them (Home, a dead deep link, the lock screen). -->
|
|
756
|
+
<slot v-if="bodyOverride" name="body"></slot>
|
|
757
|
+
|
|
758
|
+
<!-- Initial load: skeleton ghosts (view-mode aware) instead of an
|
|
759
|
+
empty/"no files" flash. Only when there is nothing yet — navigation
|
|
760
|
+
keeps the current list. -->
|
|
761
|
+
<div v-else-if="paneLoading && paneRows.length === 0" class="fe__skeleton" role="status">
|
|
762
|
+
<span class="fe-sr-only">{{ t('loading') }}</span>
|
|
763
|
+
<div v-if="viewMode !== 'list'" class="fe-skel-grid" aria-hidden="true">
|
|
764
|
+
<div v-for="i in 8" :key="i" class="fe-skel-card">
|
|
765
|
+
<div class="fe-skel fe-skel--thumb"></div>
|
|
766
|
+
<div class="fe-skel fe-skel--label"></div>
|
|
767
|
+
</div>
|
|
768
|
+
</div>
|
|
769
|
+
<div v-else class="fe-skel-list" aria-hidden="true">
|
|
770
|
+
<div v-for="i in 8" :key="i" class="fe-skel-row">
|
|
771
|
+
<div class="fe-skel fe-skel--icon"></div>
|
|
772
|
+
<div class="fe-skel fe-skel--name"></div>
|
|
773
|
+
<div class="fe-skel fe-skel--size"></div>
|
|
774
|
+
<div class="fe-skel fe-skel--date"></div>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
</div>
|
|
778
|
+
|
|
779
|
+
<!-- Listing failed with nothing else to show: a retryable error state in
|
|
780
|
+
the same visual language, in BOTH panes. The right-hand half used to
|
|
781
|
+
print one unstyled line and a bare button. -->
|
|
782
|
+
<div v-else-if="paneError && paneRows.length === 0" class="fe-state">
|
|
783
|
+
<svg
|
|
784
|
+
class="fe-state__art"
|
|
785
|
+
viewBox="0 0 120 100"
|
|
786
|
+
width="110"
|
|
787
|
+
height="92"
|
|
788
|
+
fill="none"
|
|
789
|
+
stroke="currentColor"
|
|
790
|
+
stroke-width="2"
|
|
791
|
+
stroke-linecap="round"
|
|
792
|
+
stroke-linejoin="round"
|
|
793
|
+
aria-hidden="true"
|
|
794
|
+
>
|
|
795
|
+
<circle cx="60" cy="50" r="28" />
|
|
796
|
+
<path d="M60 36v18" />
|
|
797
|
+
<circle cx="60" cy="63" r="1.8" fill="currentColor" stroke="none" />
|
|
798
|
+
<path d="M24 88h72" stroke-dasharray="3 5" />
|
|
799
|
+
</svg>
|
|
800
|
+
<p class="fe-state__title">{{ t('error.title') }}</p>
|
|
801
|
+
<p class="fe-state__hint">{{ t('error.hint') }}</p>
|
|
802
|
+
<div class="fe-state__actions">
|
|
803
|
+
<button
|
|
804
|
+
type="button"
|
|
805
|
+
class="fe-btn fe-btn--primary"
|
|
806
|
+
@click="selfDriven ? loadFolder() : emit('retry')"
|
|
807
|
+
>
|
|
808
|
+
{{ t('error.retry') }}
|
|
809
|
+
</button>
|
|
810
|
+
</div>
|
|
811
|
+
<details class="fe-state__details">
|
|
812
|
+
<summary class="fe-state__details-summary">{{ t('error.details') }}</summary>
|
|
813
|
+
<pre class="fe-state__details-pre">{{ paneError }}</pre>
|
|
814
|
+
</details>
|
|
815
|
+
</div>
|
|
816
|
+
|
|
817
|
+
<!-- The folder HAS rows and the filters hid all of them. "This folder is
|
|
818
|
+
empty" would be false, and the way back is the chip row just above,
|
|
819
|
+
so the message names it and offers the button. -->
|
|
820
|
+
<div
|
|
821
|
+
v-else-if="!paneLoading && filtersOn && displayFiles.length === 0 && paneRows.length > 0"
|
|
822
|
+
class="fe-state"
|
|
823
|
+
data-testid="empty-filtered"
|
|
824
|
+
>
|
|
825
|
+
<svg
|
|
826
|
+
class="fe-state__art"
|
|
827
|
+
viewBox="0 0 120 100"
|
|
828
|
+
width="110"
|
|
829
|
+
height="92"
|
|
830
|
+
fill="none"
|
|
831
|
+
stroke="currentColor"
|
|
832
|
+
stroke-width="2"
|
|
833
|
+
stroke-linecap="round"
|
|
834
|
+
stroke-linejoin="round"
|
|
835
|
+
aria-hidden="true"
|
|
836
|
+
>
|
|
837
|
+
<path d="M26 28h68L68 58v24l-16 8V58z" />
|
|
838
|
+
</svg>
|
|
839
|
+
<p class="fe-state__title">{{ t('filter.empty.title') }}</p>
|
|
840
|
+
<p class="fe-state__hint">{{ t('filter.empty.hint') }}</p>
|
|
841
|
+
<div class="fe-state__actions">
|
|
842
|
+
<button type="button" class="fe-btn" @click="emit('clear-filters')">
|
|
843
|
+
{{ t('filter.clear') }}
|
|
844
|
+
</button>
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
|
|
848
|
+
<!-- Loaded, zero rows: the host's own empty state when it has one to
|
|
849
|
+
offer (an empty search, an empty panel view, an empty trash), this
|
|
850
|
+
pane's plain one otherwise. -->
|
|
851
|
+
<slot
|
|
852
|
+
v-else-if="!paneLoading && paneRows.length === 0"
|
|
853
|
+
name="empty"
|
|
854
|
+
>
|
|
855
|
+
<div class="fe-state">
|
|
856
|
+
<svg
|
|
857
|
+
class="fe-state__art"
|
|
858
|
+
viewBox="0 0 120 100"
|
|
859
|
+
width="110"
|
|
860
|
+
height="92"
|
|
861
|
+
fill="none"
|
|
862
|
+
stroke="currentColor"
|
|
863
|
+
stroke-width="2"
|
|
864
|
+
stroke-linecap="round"
|
|
865
|
+
stroke-linejoin="round"
|
|
866
|
+
aria-hidden="true"
|
|
867
|
+
>
|
|
868
|
+
<path d="M18 36v42a6 6 0 0 0 6 6h72a6 6 0 0 0 6-6V44a6 6 0 0 0-6-6H62l-9-10H24a6 6 0 0 0-6 6z" />
|
|
869
|
+
</svg>
|
|
870
|
+
<p class="fe-state__title">{{ t('empty.folder') }}</p>
|
|
871
|
+
</div>
|
|
872
|
+
</slot>
|
|
873
|
+
|
|
874
|
+
<ListView
|
|
875
|
+
v-else-if="viewMode === 'list'"
|
|
876
|
+
:files="displayFiles"
|
|
877
|
+
:order="order"
|
|
878
|
+
:selected="selected"
|
|
879
|
+
:clipped="clipped"
|
|
880
|
+
:show-parent-path="showParentPath"
|
|
881
|
+
:locale="locale"
|
|
882
|
+
:loading="paneLoading"
|
|
883
|
+
:folder-key="folderKey"
|
|
884
|
+
:theme="theme"
|
|
885
|
+
:thumb-src="thumbSrc"
|
|
886
|
+
:keep-badge-for="keepBadgeFor"
|
|
887
|
+
:starred-ids="starredIds"
|
|
888
|
+
:star-enabled="starEnabled"
|
|
889
|
+
:api-base="apiBase"
|
|
890
|
+
:auth-headers="authHeaders"
|
|
891
|
+
:auth-credentials="authCredentials"
|
|
892
|
+
@click-row="onViewClick"
|
|
893
|
+
@display-order="(nodes: FileNode[]) => emit('display-order', nodes)"
|
|
894
|
+
@dbl-row="onRowOpen"
|
|
895
|
+
@context-row="onViewContext"
|
|
896
|
+
@item-drag-start="onRowDragStart"
|
|
897
|
+
@item-drop-into="onViewDropInto"
|
|
898
|
+
@star-change="(n: FileNode, v: boolean) => emit('star-change', n, v)"
|
|
899
|
+
/>
|
|
900
|
+
<GridView
|
|
901
|
+
v-else-if="viewMode === 'grid'"
|
|
902
|
+
:files="displayFiles"
|
|
903
|
+
:order="order"
|
|
904
|
+
:sections="true"
|
|
905
|
+
:selected="selected"
|
|
906
|
+
:clipped="clipped"
|
|
907
|
+
:show-parent-path="showParentPath"
|
|
908
|
+
:locale="locale"
|
|
909
|
+
:loading="paneLoading"
|
|
910
|
+
:keep-badge-for="keepBadgeFor"
|
|
911
|
+
:thumb-src="thumbSrc"
|
|
912
|
+
:e2e-active="e2eActive"
|
|
913
|
+
:starred-ids="starredIds"
|
|
914
|
+
:star-enabled="starEnabled"
|
|
915
|
+
:api-base="apiBase"
|
|
916
|
+
:auth-headers="authHeaders"
|
|
917
|
+
:auth-credentials="authCredentials"
|
|
918
|
+
@click-card="onViewClick"
|
|
919
|
+
@display-order="(nodes: FileNode[]) => emit('display-order', nodes)"
|
|
920
|
+
@dbl-card="onRowOpen"
|
|
921
|
+
@context-card="onViewContext"
|
|
922
|
+
@item-drag-start="onRowDragStart"
|
|
923
|
+
@item-drop-into="onViewDropInto"
|
|
924
|
+
@star-change="(n: FileNode, v: boolean) => emit('star-change', n, v)"
|
|
925
|
+
/>
|
|
926
|
+
<GalleryView
|
|
927
|
+
v-else
|
|
928
|
+
:files="displayFiles"
|
|
929
|
+
:order="order"
|
|
930
|
+
:selected="selected"
|
|
931
|
+
:clipped="clipped"
|
|
932
|
+
:show-parent-path="showParentPath"
|
|
933
|
+
:locale="locale"
|
|
934
|
+
:loading="paneLoading"
|
|
935
|
+
:thumb-src="thumbSrc"
|
|
936
|
+
:e2e-active="e2eActive"
|
|
937
|
+
:starred-ids="starredIds"
|
|
938
|
+
:star-enabled="starEnabled"
|
|
939
|
+
:api-base="apiBase"
|
|
940
|
+
:auth-headers="authHeaders"
|
|
941
|
+
:auth-credentials="authCredentials"
|
|
942
|
+
@click-card="onViewClick"
|
|
943
|
+
@dbl-card="onRowOpen"
|
|
944
|
+
@context-card="onViewContext"
|
|
945
|
+
@item-drag-start="onRowDragStart"
|
|
946
|
+
@item-drop-into="onViewDropInto"
|
|
947
|
+
@star-change="(n: FileNode, v: boolean) => emit('star-change', n, v)"
|
|
948
|
+
/>
|
|
949
|
+
</div>
|
|
950
|
+
</section>
|
|
951
|
+
</template>
|