@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
|
@@ -6,14 +6,60 @@
|
|
|
6
6
|
* just emit the events and let FileExplorer.vue handle them uniformly
|
|
7
7
|
* across List and Grid.
|
|
8
8
|
*/
|
|
9
|
-
import { computed, ref } from 'vue';
|
|
9
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
10
10
|
import { hasInternalDrag } from '../lib/dragOut';
|
|
11
11
|
import type { FileNode } from '../types/FileNode';
|
|
12
|
-
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
12
|
+
import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
|
|
13
13
|
import { useLocale } from '../composables/useLocale';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
arrivedFromOutside,
|
|
16
|
+
ownedByViewer,
|
|
17
|
+
ownerIdOf,
|
|
18
|
+
ownerNameOf,
|
|
19
|
+
} from '../lib/fileFilters';
|
|
20
|
+
import {
|
|
21
|
+
encryptedFolderTile,
|
|
22
|
+
fileIconTile,
|
|
23
|
+
isEncryptedFolder,
|
|
24
|
+
isStorageRow,
|
|
25
|
+
typeLabelFor,
|
|
26
|
+
} from '../lib/fileIcons'; /* gorunum:v1-preview · ikon:emoji */
|
|
27
|
+
import {
|
|
28
|
+
setSortLocale,
|
|
29
|
+
useSortStore,
|
|
30
|
+
type ListingOrder,
|
|
31
|
+
type SortKey,
|
|
32
|
+
} from '../lib/sortOrder'; /* surucu:d1-sort */
|
|
15
33
|
import { matchedInContent, snippetSegments } from '../lib/snippet'; /* bul:s3 */
|
|
34
|
+
import { drawsAsPage, previewKindFor } from '../lib/filePreview'; /* tablo:t1 */
|
|
16
35
|
import { applyDragGhost } from '../lib/dragGhost'; /* wiring:c4 */
|
|
36
|
+
import { parentDirOf } from '../lib/listing'; /* Location column — one split rule for every view */
|
|
37
|
+
import {
|
|
38
|
+
groupByDate,
|
|
39
|
+
groupingActive,
|
|
40
|
+
type DateRun,
|
|
41
|
+
} from '../lib/dateGroups'; /* zaman:z1 · gruplama */
|
|
42
|
+
import {
|
|
43
|
+
COLUMNS,
|
|
44
|
+
columnHidden,
|
|
45
|
+
columnWidth,
|
|
46
|
+
columnsCustomised,
|
|
47
|
+
canMoveColumn,
|
|
48
|
+
columnOrder,
|
|
49
|
+
folderIsRemembered,
|
|
50
|
+
freezeWidths,
|
|
51
|
+
moveColumn,
|
|
52
|
+
moveColumnBy,
|
|
53
|
+
forgetAllFolders,
|
|
54
|
+
forgetFolder,
|
|
55
|
+
rememberedCount,
|
|
56
|
+
resetColumns,
|
|
57
|
+
setColumnHidden,
|
|
58
|
+
setColumnWidth,
|
|
59
|
+
tableLayout,
|
|
60
|
+
widthsAreAuto,
|
|
61
|
+
type ColumnId,
|
|
62
|
+
} from '../lib/viewPrefs'; /* tablo:t1 */
|
|
17
63
|
import StarButton from './StarButton.vue';
|
|
18
64
|
|
|
19
65
|
const props = defineProps<{
|
|
@@ -53,6 +99,42 @@ const props = defineProps<{
|
|
|
53
99
|
* computer / syncing right now / online only). Absent on the web —
|
|
54
100
|
* no badge renders at all. */
|
|
55
101
|
keepBadgeFor?: (n: FileNode) => 'kept' | 'syncing' | 'cloud' | 'partial' | null;
|
|
102
|
+
/**
|
|
103
|
+
* surucu:d1-sort — where these rows got their order (`lib/sortOrder`).
|
|
104
|
+
*
|
|
105
|
+
* `relevance` means the server ranked them: this view then draws them in
|
|
106
|
+
* the order they arrived, groups nothing by date and closes its column
|
|
107
|
+
* sorts. Omitted = an ordinary folder listing, sorted by the active key —
|
|
108
|
+
* which is what every caller that does not search passes, including the
|
|
109
|
+
* split view's secondary pane.
|
|
110
|
+
*/
|
|
111
|
+
order?: ListingOrder;
|
|
112
|
+
/**
|
|
113
|
+
* tablo:t1 — the key the folder on screen is remembered under
|
|
114
|
+
* (`lib/viewPrefs.folderKey`), or '' when this listing has no folder behind
|
|
115
|
+
* it or the host turned the memory off. The column menu reads it for its
|
|
116
|
+
* two per-folder rows and nothing else does; the memory itself is written by
|
|
117
|
+
* the caller, which is the only thing that knows when a navigation ended.
|
|
118
|
+
*/
|
|
119
|
+
folderKey?: string;
|
|
120
|
+
/**
|
|
121
|
+
* tablo:t1 — the resolved theme, forwarded to the column menu.
|
|
122
|
+
*
|
|
123
|
+
* ⚠ Needed because that menu is teleported to <body>, where the `.fe`
|
|
124
|
+
* ancestor that scopes the design tokens is gone — the same reason
|
|
125
|
+
* `ContextMenu` takes one, and the class it tags itself with is the same one
|
|
126
|
+
* `variables.css` already re-applies dark values under.
|
|
127
|
+
*/
|
|
128
|
+
theme?: ThemeMode;
|
|
129
|
+
/**
|
|
130
|
+
* tablo:t1 — the authenticated thumbnail resolver, the same one the grid and
|
|
131
|
+
* the gallery are handed (`useThumbs.src`).
|
|
132
|
+
*
|
|
133
|
+
* ⚠ Optional, and the list draws its type tile when it is absent: an embed
|
|
134
|
+
* with no auth wire-up cannot fetch a thumb at all, and a broken <img> in
|
|
135
|
+
* every row would be worse than the glyph it replaced.
|
|
136
|
+
*/
|
|
137
|
+
thumbSrc?: (n: FileNode) => string | null;
|
|
56
138
|
}>();
|
|
57
139
|
|
|
58
140
|
const emit = defineEmits<{
|
|
@@ -62,9 +144,29 @@ const emit = defineEmits<{
|
|
|
62
144
|
(e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
|
|
63
145
|
(e: 'item-drop-into', target: FileNode, ev: DragEvent): void;
|
|
64
146
|
(e: 'star-change', node: FileNode, value: boolean): void;
|
|
147
|
+
/**
|
|
148
|
+
* gorunum:v1 — the order this view actually renders.
|
|
149
|
+
*
|
|
150
|
+
* A shift-range is arithmetic over a list, and until now the parent did that
|
|
151
|
+
* arithmetic over the BACKEND's order while the user was looking at a sorted
|
|
152
|
+
* one. Sorting by size and shift-clicking two adjacent rows selected
|
|
153
|
+
* whatever happened to lie between them in the server's answer. Hoisting
|
|
154
|
+
* folders made it reachable without sorting anything, which is how it was
|
|
155
|
+
* found. The view knows what it drew; it says so.
|
|
156
|
+
*/
|
|
157
|
+
(e: 'display-order', nodes: FileNode[]): void;
|
|
65
158
|
}>();
|
|
66
159
|
|
|
67
|
-
const {
|
|
160
|
+
const {
|
|
161
|
+
t,
|
|
162
|
+
formatSize,
|
|
163
|
+
formatDate,
|
|
164
|
+
formatDateFull,
|
|
165
|
+
formatMonthYear,
|
|
166
|
+
zonedYearMonth,
|
|
167
|
+
toDate,
|
|
168
|
+
nodeDisplayName,
|
|
169
|
+
} = useLocale(() => props.locale);
|
|
68
170
|
|
|
69
171
|
function isSelected(n: FileNode): boolean {
|
|
70
172
|
return props.selected.has(n.path);
|
|
@@ -86,6 +188,42 @@ function onRowCtx(n: FileNode, ev: MouseEvent) {
|
|
|
86
188
|
emit('context-row', n, ev);
|
|
87
189
|
}
|
|
88
190
|
|
|
191
|
+
/**
|
|
192
|
+
* gorunum:v1 — the row's ⋮ button opens THE SAME menu the right-click opens.
|
|
193
|
+
*
|
|
194
|
+
* It emits `context-row` with the same node, i.e. it walks the caller's
|
|
195
|
+
* existing context-menu path (`onContextTarget` → `selectionActionList`) with
|
|
196
|
+
* a synthetic event carrying the button's own corner as the anchor. There is
|
|
197
|
+
* deliberately no second action list here: a menu assembled beside the real
|
|
198
|
+
* one drifts from it the first time an action is added, and the row's "⋮" and
|
|
199
|
+
* its right-click would then offer different things on the same file
|
|
200
|
+
* (filex lesson #67).
|
|
201
|
+
*/
|
|
202
|
+
function onRowMenu(n: FileNode, ev: MouseEvent) {
|
|
203
|
+
const el = ev.currentTarget as HTMLElement | null;
|
|
204
|
+
const box = el?.getBoundingClientRect();
|
|
205
|
+
emit('context-row', n, {
|
|
206
|
+
clientX: box ? box.left : ev.clientX,
|
|
207
|
+
clientY: box ? box.bottom : ev.clientY,
|
|
208
|
+
preventDefault: () => {},
|
|
209
|
+
stopPropagation: () => {},
|
|
210
|
+
} as unknown as MouseEvent);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* gorunum:v1 — the row checkbox drives THE SAME selection the row click
|
|
215
|
+
* drives: one `click-row` emit, one `useSelection.click`, one anchor. A
|
|
216
|
+
* checkbox that kept its own Set would disagree with the row the first time
|
|
217
|
+
* someone mixed the two, and the context menu acts on the row's set.
|
|
218
|
+
*
|
|
219
|
+
* `ctrl: true` because ticking a box adds to a selection rather than replacing
|
|
220
|
+
* it; `shift` is passed through so shift-clicking a box still extends the
|
|
221
|
+
* range from the anchor (the composable answers shift before ctrl).
|
|
222
|
+
*/
|
|
223
|
+
function onCheckClick(n: FileNode, ev: MouseEvent) {
|
|
224
|
+
emit('click-row', n, { ctrl: true, shift: ev.shiftKey });
|
|
225
|
+
}
|
|
226
|
+
|
|
89
227
|
function onItemDragStart(n: FileNode, ev: DragEvent) {
|
|
90
228
|
/* wiring:c4 — custom ghost (name + multi-select count badge). Visual
|
|
91
229
|
* only; the payload/selection logic stays in FileExplorer. */
|
|
@@ -149,15 +287,6 @@ function cancelPress() {
|
|
|
149
287
|
pressTarget = null;
|
|
150
288
|
}
|
|
151
289
|
|
|
152
|
-
// Special rows keep their emoji (trash/storage are not file-TYPE icons);
|
|
153
|
-
// everything else renders the SVG icon set from lib/fileIcons.
|
|
154
|
-
function specialEmojiFor(n: FileNode): string | null {
|
|
155
|
-
if (n.basename === '.trash') return '🗑';
|
|
156
|
-
if (n.mime_type === 'inode/storage') return '💾';
|
|
157
|
-
if (n.type === 'dir' && n.e2e === true) return '🔒'; /* wiring:e2 — encrypted-folder badge */
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
290
|
function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
|
|
162
291
|
if (b === 'kept') return '\u2713';
|
|
163
292
|
if (b === 'syncing') return '\u27f3';
|
|
@@ -166,20 +295,101 @@ function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
|
|
|
166
295
|
}
|
|
167
296
|
|
|
168
297
|
function isPinnedSpecial(n: FileNode): boolean {
|
|
169
|
-
return n.basename === '.trash' || n
|
|
298
|
+
return n.basename === '.trash' || isStorageRow(n);
|
|
170
299
|
}
|
|
171
300
|
|
|
172
301
|
function displayDate(ms: number | undefined): string {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
302
|
+
return formatDate(ms, { time: true }) || '—';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* zaman:z1 — the hover text behind the date cell: the whole instant, named
|
|
307
|
+
* with the zone it is being read in. The cell itself has room for
|
|
308
|
+
* "Sep 12, 2026, 3:14 AM" and no room to say whose 3:14 that is.
|
|
309
|
+
*/
|
|
310
|
+
function fullDate(ms: number | undefined): string {
|
|
311
|
+
return formatDateFull(ms);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* gorunum:v1-preview — the Type column names the KIND: `TypeScript`,
|
|
316
|
+
* `Spreadsheet`, `PDF document`, `Folder`.
|
|
317
|
+
*
|
|
318
|
+
* ⚠ This reverses the note that stood here, which argued the raw extension in
|
|
319
|
+
* caps was "the only kind fact a listing actually carries" and that a name
|
|
320
|
+
* table would only repeat what the coloured tile says. Both halves were wrong.
|
|
321
|
+
* `TS` is not a fact about the kind, it is the suffix someone typed; and the
|
|
322
|
+
* tile says *family* (one glyph covers .ts, .go, .css and .sql), which is
|
|
323
|
+
* exactly the distinction a column headed "Type" is there to make. The owner
|
|
324
|
+
* asked for the words, and the words are strictly more informative.
|
|
325
|
+
*
|
|
326
|
+
* The table itself is in `lib/fileIcons` beside the extension → family map it
|
|
327
|
+
* is built on, so this column, the info panel and anything else that names a
|
|
328
|
+
* kind read one answer (filex lesson #67). Everything unmapped still falls
|
|
329
|
+
* back to the uppercased extension — no empty cells.
|
|
330
|
+
*
|
|
331
|
+
* The pinned virtual rows (Trash, a storage drive) keep their em dash: Trash
|
|
332
|
+
* is a `type: 'dir'` sentinel, and calling it a folder in this column is the
|
|
333
|
+
* sort of small lie that sends someone looking for it on disk.
|
|
334
|
+
*/
|
|
335
|
+
function typeLabel(n: FileNode): string {
|
|
336
|
+
if (isPinnedSpecial(n)) return '—';
|
|
337
|
+
return typeLabelFor(n, t);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* tablo:t1 — THE ROW'S PICTURE, where there is a real one to show.
|
|
342
|
+
*
|
|
343
|
+
* The grid and the gallery have drawn true thumbnails for a while; the list
|
|
344
|
+
* drew a type glyph for every row, including a 38 KB photograph. The data was
|
|
345
|
+
* always there and the endpoint always worked — the list simply never asked.
|
|
346
|
+
*
|
|
347
|
+
* ⚠⚠ It asks under exactly the grid's condition, not a second one of its own.
|
|
348
|
+
* `previewKindFor` is non-null for anything we can read as text, and for those
|
|
349
|
+
* the backend's "thumbnail" is a generated rectangle with the extension
|
|
350
|
+
* stencilled on it — the same three letters the Type column prints in words
|
|
351
|
+
* two inches to the right. Skipping the call for them is what keeps this
|
|
352
|
+
* costing zero extra requests rather than one per row; copying the rule rather
|
|
353
|
+
* than restating it is what stops the list and the grid disagreeing about
|
|
354
|
+
* which files have pictures (filex lesson #67).
|
|
355
|
+
*/
|
|
356
|
+
function thumbOf(n: FileNode): string | null {
|
|
357
|
+
if (n.type === 'dir') return null;
|
|
358
|
+
if (previewKindFor(n)) return null;
|
|
359
|
+
return props.thumbSrc ? props.thumbSrc(n) : (n.thumb_url ?? null);
|
|
176
360
|
}
|
|
177
361
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
362
|
+
/** The muted "Folder" said beside a directory's name — not on the sentinels. */
|
|
363
|
+
function isPlainDir(n: FileNode): boolean {
|
|
364
|
+
return n.type === 'dir' && !isPinnedSpecial(n);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* tablo:t1 — WHERE THE ROW LIVES, for the Location column.
|
|
369
|
+
*
|
|
370
|
+
* ⚠⚠ This used to be a second line printed UNDER the file's name, and that is
|
|
371
|
+
* the defect the owner reported, verbatim: "name yazılarının altında uzun tire
|
|
372
|
+
* görüyorum aynı modified'da olduğu gibi… her değer kendi sütununda gözükmesi
|
|
373
|
+
* gerekmekte" — a long dash under the name text, and every value belongs in
|
|
374
|
+
* its own column. Measured on Recent, where every row sits at its storage's
|
|
375
|
+
* root: the parent folder was '' for all of them, the template printed the
|
|
376
|
+
* `|| '—'` fallback, and eleven em dashes hung under eleven filenames. A value
|
|
377
|
+
* with no column of its own has nowhere to be absent from, so it had to invent
|
|
378
|
+
* a placeholder; give it a column and "no sub-folder" is simply an empty cell.
|
|
379
|
+
*
|
|
380
|
+
* The storage leads, so this reads the way the reference build's own Location
|
|
381
|
+
* column does (`demo`, `demo/Design`) and a row at the root of a drive still
|
|
382
|
+
* says which drive.
|
|
383
|
+
*
|
|
384
|
+
* ⚠ The folder comes from `parentDirOf` (lib/listing), which splits on the
|
|
385
|
+
* first `://`. This file had its own copy that stripped a URL-scheme pattern
|
|
386
|
+
* instead, and a storage NAME is not a scheme: for `My files` nothing was
|
|
387
|
+
* stripped and this printed `My files/My files://Photos` (2026-09-14).
|
|
388
|
+
*/
|
|
389
|
+
function locationLabel(n: FileNode): string {
|
|
390
|
+
const dir = parentDirOf(n.path);
|
|
391
|
+
const store = String((n as Record<string, unknown>).storage ?? '').trim();
|
|
392
|
+
return [store, dir].filter(Boolean).join('/');
|
|
183
393
|
}
|
|
184
394
|
|
|
185
395
|
/* bul:s3 — search-result enrichment. The v0.2 backend inlines `snippet`
|
|
@@ -196,181 +406,814 @@ function rowInContent(n: FileNode): boolean {
|
|
|
196
406
|
}
|
|
197
407
|
|
|
198
408
|
// ------------------------------------------------------------------
|
|
199
|
-
// Column sorting
|
|
200
|
-
//
|
|
409
|
+
// Column sorting.
|
|
410
|
+
//
|
|
411
|
+
// ⚠⚠ NOT local to the list view any more. The key, the direction, the click
|
|
412
|
+
// cycle and the comparator all live in `lib/sortOrder`, the module-level
|
|
413
|
+
// singleton the filter row's sort control also drives — so these headers and
|
|
414
|
+
// that control are two handles on ONE piece of state rather than two sorts
|
|
415
|
+
// racing to re-order the same rows (filex lesson #67, one level up from the
|
|
416
|
+
// "folders first" copy it is about). The headers kept their behaviour: click
|
|
417
|
+
// a column to sort by it, click it again to reverse.
|
|
418
|
+
//
|
|
419
|
+
// ⚠⚠ The old third click — "back to the backend's order" — is back, but as a
|
|
420
|
+
// property of the ROWS rather than of the click: `order === 'relevance'` says
|
|
421
|
+
// the server ranked this listing, and then no key is applied, no column
|
|
422
|
+
// claims to be sorting, and these headers close. The filter row's button says
|
|
423
|
+
// "Relevance" and why, so the state the list is in is one the control can
|
|
424
|
+
// describe — which is the thing that made `key: null` unworkable as a key.
|
|
425
|
+
//
|
|
426
|
+
// ⚠ Closing the headers is not politeness. A header click while a ranked
|
|
427
|
+
// listing is on screen would set the shared key, move nothing at all, and
|
|
428
|
+
// then re-order the FOLDER the person returns to — a control whose only
|
|
429
|
+
// effect is somewhere the user is not looking.
|
|
201
430
|
// ------------------------------------------------------------------
|
|
202
431
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
432
|
+
/** The Type column sorts too now, so every key the filter row's Sort-by menu
|
|
433
|
+
* offers is reachable from a header as well. */
|
|
434
|
+
/* ⚠⚠ The store of the PANE this list is inside, not a module-level singleton.
|
|
435
|
+
* Two panes of a split each draw a listing and each carry a sort control; one
|
|
436
|
+
* shared answer means the control in the right pane re-orders the left one.
|
|
437
|
+
* With no provider (a bare <ListView>, a host with one listing) this is the
|
|
438
|
+
* default store, i.e. exactly the old behaviour. */
|
|
439
|
+
const sort = useSortStore();
|
|
440
|
+
const sortKey = computed<SortKey>(() => sort.key.value);
|
|
441
|
+
const sortDir = computed(() => sort.dir.value);
|
|
442
|
+
/** These rows are the server's ranked answer — see the `order` prop. */
|
|
443
|
+
const ranked = computed(() => props.order === 'relevance');
|
|
444
|
+
|
|
445
|
+
/* `type` sorts by the word this very column prints, so the comparator needs
|
|
446
|
+
* the catalogue that word comes from. */
|
|
447
|
+
watch(() => props.locale, (l) => setSortLocale(l), { immediate: true });
|
|
448
|
+
|
|
449
|
+
function toggleSort(key: SortKey) {
|
|
450
|
+
/* ⚠⚠ The `disabled` attribute on the header is the AFFORDANCE, not the
|
|
451
|
+
* rule. A disabled button suppresses the browser's own activation, but any
|
|
452
|
+
* click dispatched in script still runs this listener — and an embedder is
|
|
453
|
+
* free to restyle or re-render these headers. The consequence of letting one
|
|
454
|
+
* through is invisible and delayed: nothing moves on screen (the listing is
|
|
455
|
+
* ranked), the shared key is written to `filex.list-sort`, and the FOLDER
|
|
456
|
+
* the person returns to comes back in an order they never asked for.
|
|
457
|
+
* Measured 2026-09-13: a scripted click on the closed Size header left the
|
|
458
|
+
* rows untouched and still turned the stored sort from Name ↑ into Size ↑. */
|
|
459
|
+
if (ranked.value) return;
|
|
460
|
+
sort.chooseSortKey(key);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** What a column header promises. Over a ranked listing the headers are
|
|
464
|
+
* closed, so they say WHY rather than going quietly grey — the same sentence
|
|
465
|
+
* the filter row's sort button carries, because it is the same fact. */
|
|
466
|
+
const sortTitle = computed(() => t(ranked.value ? 'sort.relevance_why' : 'col.sort'));
|
|
467
|
+
|
|
468
|
+
function ariaSort(key: SortKey): 'ascending' | 'descending' | 'none' {
|
|
469
|
+
// ⚠ 'none' for every column over a ranked listing: `aria-sort` is a claim
|
|
470
|
+
// about the rows below, and the rows below are in the server's order.
|
|
471
|
+
if (ranked.value || sortKey.value !== key) return 'none';
|
|
472
|
+
return sortDir.value === 'asc' ? 'ascending' : 'descending';
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ------------------------------------------------------------------
|
|
476
|
+
// tablo:t2 — the columns: how wide, and who decides.
|
|
477
|
+
//
|
|
478
|
+
// ⚠⚠ THE LAYOUT IS COMPUTED HERE, IN JAVASCRIPT, and applied as inline widths
|
|
479
|
+
// — it is not a stylesheet rule. It used to be three places at once (a base
|
|
480
|
+
// template in `base.css`, a `.fe--narrow` override restating it and a
|
|
481
|
+
// `@media (max-width: 640px)` rule restating it again, each with its own list
|
|
482
|
+
// of cells to `display: none`), and they disagreed: the media query answers
|
|
483
|
+
// the VIEWPORT while `.fe--narrow` answers the CONTAINER, so an embed 500px
|
|
484
|
+
// wide on a 1400px screen got one rule's hiding and the other rule's tracks.
|
|
485
|
+
//
|
|
486
|
+
// ⚠⚠ IT IS A REAL TABLE NOW, not a fit-to-container layout. The previous
|
|
487
|
+
// arrangement sized the columns to the pane and, when they stopped fitting,
|
|
488
|
+
// SHED the least informative one and told the person "no room" in the header
|
|
489
|
+
// menu. The owner rejected the model, verbatim: "büyütme küçültme düzgün
|
|
490
|
+
// çalışmıyor, tablo gibi durmuyor orası. Çok büyütünce yok oluyor, 'no room'
|
|
491
|
+
// diyor — no room demesin, kenara devam eden bir scroll getirsin." So the
|
|
492
|
+
// table's width is the sum of its columns, the pane scrolls sideways when that
|
|
493
|
+
// is more than it has, the ⋮ stays pinned to the right edge, and a table
|
|
494
|
+
// narrower than its pane sits at the LEFT with the slack after it.
|
|
495
|
+
// ------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
/** Which optional columns this listing is willing to draw at all, before any
|
|
498
|
+
* question of width or of the person's own choices. */
|
|
499
|
+
const candidateCols = computed<ColumnId[]>(() => {
|
|
500
|
+
const out: ColumnId[] = ['type'];
|
|
501
|
+
/* Location only where the rows come from more than one folder — the same
|
|
502
|
+
condition that used to draw the parent path under the name. In an ordinary
|
|
503
|
+
folder every row shares one location and the column would be a wall of the
|
|
504
|
+
same string. */
|
|
505
|
+
if (props.showParentPath) out.push('location');
|
|
506
|
+
out.push('owner', 'modified', 'size');
|
|
507
|
+
if (props.starEnabled !== false && (!!props.apiBase || props.apiBase === '')) out.push('star');
|
|
508
|
+
return out;
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* The width the list actually has.
|
|
513
|
+
*
|
|
514
|
+
* ⚠ Its OWN width, watched with a ResizeObserver, not the window's. An
|
|
515
|
+
* explorer embedded in a side panel is narrow on a wide screen, and that is
|
|
516
|
+
* the case the old media query could not see. It is also the SCROLLPORT's
|
|
517
|
+
* width — `.fe-list` is the scrolling box now, so `contentRect` already has
|
|
518
|
+
* the vertical scrollbar taken out of it and the auto widths are computed
|
|
519
|
+
* against the room a row really has. 0 until the first observation, which
|
|
520
|
+
* `tableLayout` reads as "keep the shipped widths" so the first paint is not a
|
|
521
|
+
* guess that then jumps.
|
|
522
|
+
*/
|
|
523
|
+
const listEl = ref<HTMLElement | null>(null);
|
|
524
|
+
const listWidth = ref(0);
|
|
525
|
+
let ro: ResizeObserver | null = null;
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Is the table scrolled off its left edge?
|
|
529
|
+
*
|
|
530
|
+
* Only used to draw the pinned ⋮ column's edge. A permanent divider there
|
|
531
|
+
* would claim the column is floating when it is simply the last cell of a
|
|
532
|
+
* table that fits.
|
|
533
|
+
*/
|
|
534
|
+
const scrolledX = ref(false);
|
|
535
|
+
|
|
536
|
+
function onListScroll(ev: Event) {
|
|
537
|
+
const el = ev.currentTarget as HTMLElement | null;
|
|
538
|
+
const on = (el?.scrollLeft ?? 0) > 0;
|
|
539
|
+
if (on !== scrolledX.value) scrolledX.value = on;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
onMounted(() => {
|
|
543
|
+
if (!listEl.value || typeof ResizeObserver === 'undefined') return;
|
|
544
|
+
ro = new ResizeObserver((entries) => {
|
|
545
|
+
const w = entries[0]?.contentRect?.width ?? 0;
|
|
546
|
+
if (Math.abs(w - listWidth.value) >= 1) listWidth.value = w;
|
|
547
|
+
});
|
|
548
|
+
ro.observe(listEl.value);
|
|
549
|
+
});
|
|
550
|
+
onBeforeUnmount(() => {
|
|
551
|
+
ro?.disconnect();
|
|
552
|
+
ro = null;
|
|
553
|
+
window.removeEventListener('pointermove', onResizeMove);
|
|
554
|
+
window.removeEventListener('pointerup', onResizeEnd);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const layout = computed(() => tableLayout(listWidth.value, candidateCols.value));
|
|
558
|
+
const visibleCols = computed<ColumnId[]>(() => layout.value.visible);
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* ONE object, bound to the header and to every row.
|
|
562
|
+
*
|
|
563
|
+
* ⚠⚠ The header has to be exactly as wide as the rows or it drifts out of line
|
|
564
|
+
* with its own columns the moment the table is scrolled sideways — which is
|
|
565
|
+
* worse than having no header at all, because it labels the wrong values.
|
|
566
|
+
* `min-width: 100%` in the stylesheet is what keeps a NARROW table's rows
|
|
567
|
+
* spanning the pane (so hover and selection do not stop halfway across) while
|
|
568
|
+
* the cells inside them still pack left.
|
|
569
|
+
*/
|
|
570
|
+
const tableStyle = computed(() => ({ width: `${layout.value.total}px` }));
|
|
571
|
+
|
|
572
|
+
/** A drawn track's width, as the inline style the cell carries. `flex-basis`
|
|
573
|
+
* rather than a grid track, because the pinned ⋮ has to be able to move
|
|
574
|
+
* within the row — a sticky GRID item is confined to its own grid area and
|
|
575
|
+
* therefore cannot move at all. */
|
|
576
|
+
function colStyle(id: ColumnId) {
|
|
577
|
+
return { flex: `0 0 ${layout.value.widths[id] ?? columnWidth(id)}px` };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** A column's cell class — the existing `fe-list__col--*` names, so every
|
|
581
|
+
* style already written against them keeps applying. */
|
|
582
|
+
function colClass(id: ColumnId): string {
|
|
583
|
+
return `fe-list__col fe-list__col--${id === 'modified' ? 'mod' : id}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The four keys a header can sort by, keyed by column. Location and the star
|
|
587
|
+
* are not sorts: there is no `SortKey` for either, and inventing one would put
|
|
588
|
+
* a word in the Sort-by menu that means nothing in nine listings out of ten. */
|
|
589
|
+
const SORT_OF: Partial<Record<ColumnId, SortKey>> = {
|
|
590
|
+
type: 'type',
|
|
591
|
+
modified: 'modified',
|
|
592
|
+
size: 'size',
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const COL_LABEL: Record<ColumnId, string> = {
|
|
596
|
+
name: 'col.name',
|
|
597
|
+
type: 'col.type',
|
|
598
|
+
location: 'col.location',
|
|
599
|
+
owner: 'col.owner',
|
|
600
|
+
modified: 'col.modified',
|
|
601
|
+
size: 'col.size',
|
|
602
|
+
star: 'col.star',
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
function colLabel(id: ColumnId): string {
|
|
606
|
+
return t(COL_LABEL[id]);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** What the cell prints for this row. One switch, so the head's order and the
|
|
610
|
+
* body's values are driven by the same list. */
|
|
611
|
+
function cellText(id: ColumnId, n: FileNode): string {
|
|
612
|
+
switch (id) {
|
|
613
|
+
case 'type':
|
|
614
|
+
return typeLabel(n);
|
|
615
|
+
case 'location':
|
|
616
|
+
return locationLabel(n);
|
|
617
|
+
case 'owner':
|
|
618
|
+
return ownerLabel(n);
|
|
619
|
+
case 'modified':
|
|
620
|
+
return displayDate(n.last_modified);
|
|
621
|
+
case 'size':
|
|
622
|
+
return formatSize(n.size);
|
|
623
|
+
default:
|
|
624
|
+
return '';
|
|
214
625
|
}
|
|
215
|
-
} catch {
|
|
216
|
-
/* private mode / bad JSON */
|
|
217
626
|
}
|
|
218
627
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
628
|
+
function cellTitle(id: ColumnId, n: FileNode): string | undefined {
|
|
629
|
+
if (id === 'owner') return ownerTitle(n);
|
|
630
|
+
if (id === 'modified') return fullDate(n.last_modified);
|
|
631
|
+
if (id === 'location') return locationLabel(n) || undefined;
|
|
632
|
+
return undefined;
|
|
222
633
|
}
|
|
223
634
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
635
|
+
/**
|
|
636
|
+
* The name cell's hover text.
|
|
637
|
+
*
|
|
638
|
+
* ⚠ Carries the location too when the Location column is not drawn — which
|
|
639
|
+
* now only happens when the person has turned it off, since width sheds
|
|
640
|
+
* nothing. The point of moving the parent path into a column was that every
|
|
641
|
+
* value gets a column; the point of this line is that a column somebody hid
|
|
642
|
+
* must not silently destroy the only copy of a fact, or `report.pdf` in two
|
|
643
|
+
* folders is two identical rows.
|
|
644
|
+
*/
|
|
645
|
+
function nameTitle(n: FileNode): string {
|
|
646
|
+
if (!props.showParentPath || visibleCols.value.includes('location')) return n.basename;
|
|
647
|
+
const loc = locationLabel(n);
|
|
648
|
+
return loc ? `${n.basename} · ${loc}` : n.basename;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ── resizing ──────────────────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
function isResizable(id: ColumnId): boolean {
|
|
654
|
+
return COLUMNS.find((c) => c.id === id)?.resizable === true;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* ⚠⚠ NAME HAS A HANDLE NOW, and the note that stood here said the opposite:
|
|
659
|
+
* "there is deliberately NO handle on Name… it is whatever the fixed columns
|
|
660
|
+
* leave, so dragging Name can only mean dragging everything else." That was
|
|
661
|
+
* true of the flexible `minmax(220px, 1fr)` track, and the track is what the
|
|
662
|
+
* owner rejected: "küçültme yapınca name kısmı büyüyormuş gibi davranıyor — bu
|
|
663
|
+
* davranış yanlış, istersem name'i de kısabilir olmalıyım." Name is an
|
|
664
|
+
* ordinary column with a width of its own, so its handle resizes the column it
|
|
665
|
+
* sits on, like every other.
|
|
666
|
+
*/
|
|
667
|
+
let resizing: { id: ColumnId; startX: number; startW: number } | null = null;
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* THE FIRST DRAG FREEZES THE WHOLE ROW.
|
|
671
|
+
*
|
|
672
|
+
* ⚠⚠ Not just the column under the pointer. Until somebody sizes a column the
|
|
673
|
+
* widths are derived from the pane (`tableLayout`, auto), and auto Name takes
|
|
674
|
+
* whatever slack is going — so narrowing Size while Name is still auto would
|
|
675
|
+
* widen Name, which is precisely the behaviour being fixed. Committing every
|
|
676
|
+
* drawn width the instant a gesture starts turns the drag into what it looks
|
|
677
|
+
* like, and is also what makes "I have never touched this" distinguishable
|
|
678
|
+
* from "I deliberately made Name narrow" forever after (`widthsAreAuto`).
|
|
679
|
+
*/
|
|
680
|
+
function freezeBeforeEdit() {
|
|
681
|
+
if (!widthsAreAuto()) return;
|
|
682
|
+
freezeWidths(layout.value.widths);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function onResizeStart(id: ColumnId, ev: PointerEvent) {
|
|
686
|
+
if (!isResizable(id)) return;
|
|
687
|
+
ev.preventDefault();
|
|
688
|
+
ev.stopPropagation();
|
|
689
|
+
freezeBeforeEdit();
|
|
690
|
+
resizing = { id, startX: ev.clientX, startW: columnWidth(id) };
|
|
691
|
+
/* ⚠ On the WINDOW, not on the handle with setPointerCapture: the pointer
|
|
692
|
+
* leaves the 9px handle within the first few pixels of any real drag, and a
|
|
693
|
+
* capture on an element Vue may re-render mid-drag (the template width is
|
|
694
|
+
* reactive and changes on every move) is released the moment that element is
|
|
695
|
+
* patched — the column would stop following the cursor halfway. */
|
|
696
|
+
window.addEventListener('pointermove', onResizeMove);
|
|
697
|
+
window.addEventListener('pointerup', onResizeEnd);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function onResizeMove(ev: PointerEvent) {
|
|
701
|
+
if (!resizing) return;
|
|
702
|
+
setColumnWidth(resizing.id, resizing.startW + (ev.clientX - resizing.startX));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function onResizeEnd() {
|
|
706
|
+
resizing = null;
|
|
707
|
+
window.removeEventListener('pointermove', onResizeMove);
|
|
708
|
+
window.removeEventListener('pointerup', onResizeEnd);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Keyboard resizing — the handle is focusable, so it has to do something.
|
|
712
|
+
* 16px a step, which is about one character of the 13px body text. */
|
|
713
|
+
function onResizeKey(id: ColumnId, ev: KeyboardEvent) {
|
|
714
|
+
const step = ev.key === 'ArrowLeft' ? -16 : ev.key === 'ArrowRight' ? 16 : 0;
|
|
715
|
+
if (!step) return;
|
|
716
|
+
ev.preventDefault();
|
|
717
|
+
freezeBeforeEdit();
|
|
718
|
+
setColumnWidth(id, columnWidth(id) + step);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Double-click a handle: back to this column's shipped width. "Size to fit"
|
|
722
|
+
* would mean measuring every cell in a listing that may be thousands of rows
|
|
723
|
+
* long, and the honest cheap answer is the default. */
|
|
724
|
+
function onResizeReset(id: ColumnId) {
|
|
725
|
+
const spec = COLUMNS.find((c) => c.id === id);
|
|
726
|
+
if (!spec) return;
|
|
727
|
+
freezeBeforeEdit();
|
|
728
|
+
setColumnWidth(id, spec.width);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ── reordering ────────────────────────────────────────────
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* DRAG A HEADER TO MOVE ITS COLUMN — the owner's third ask for this table:
|
|
735
|
+
* "ayrıca yerlerini falan değiştirebilir olayım ya o sütunların."
|
|
736
|
+
*
|
|
737
|
+
* ⚠⚠ The threshold is the whole difficulty. Clicking a header sorts and
|
|
738
|
+
* dragging it moves, and both start with the same pointerdown — so a reorder
|
|
739
|
+
* that begins on the first pixel of movement turns every sort click into an
|
|
740
|
+
* accidental two-pixel drag. Nothing happens here until the pointer has
|
|
741
|
+
* travelled `DRAG_SLOP`; below that the gesture stays a click and the sort
|
|
742
|
+
* button gets it, untouched. Above it, the click the browser fires on
|
|
743
|
+
* pointerup is swallowed on its way past (`onHeadClickCapture`), or every
|
|
744
|
+
* completed drag would also re-sort the column it had just moved.
|
|
745
|
+
*
|
|
746
|
+
* ⚠ Name is not in this: it is pinned first and nothing may be dropped before
|
|
747
|
+
* it. It carries the tick, the icon and the row's click target, which is why
|
|
748
|
+
* every file manager pins it — and `columnOrder` enforces it, so a document
|
|
749
|
+
* that claims otherwise cannot put a column ahead of the one the row is read
|
|
750
|
+
* by. Its WIDTH is the person's, though; only its place is fixed.
|
|
751
|
+
*/
|
|
752
|
+
const DRAG_SLOP = 5;
|
|
753
|
+
let dragState: { id: ColumnId; startX: number; armed: boolean } | null = null;
|
|
754
|
+
/** The column being dragged, once the slop is cleared. */
|
|
755
|
+
const dragCol = ref<ColumnId | null>(null);
|
|
756
|
+
/** Where it would land: an index into the drawn columns. */
|
|
757
|
+
const dropAt = ref<number | null>(null);
|
|
758
|
+
/**
|
|
759
|
+
* When a real drag last ended — the one thing standing between "I moved a
|
|
760
|
+
* column" and "I moved a column and also re-sorted it".
|
|
761
|
+
*
|
|
762
|
+
* ⚠⚠ A TIMESTAMP and not a boolean. A flag is armed on pointerup and
|
|
763
|
+
* disarmed by the click that follows — except when no click follows, which
|
|
764
|
+
* happens whenever the pointer went down on one header and up on another and
|
|
765
|
+
* the browser found no common ancestor to fire on. The flag then sits armed
|
|
766
|
+
* and eats the NEXT header click the person makes, which is a sort that
|
|
767
|
+
* silently does nothing minutes later. A window bounds the damage to the
|
|
768
|
+
* gesture it belongs to. Measured 2026-09-13: a sort click after a reorder
|
|
769
|
+
* drag did nothing at all.
|
|
770
|
+
*/
|
|
771
|
+
let dragEndedAt = 0;
|
|
772
|
+
const CLICK_SWALLOW_MS = 300;
|
|
773
|
+
/** Set by Escape, cleared by the pointerup it pre-empts. */
|
|
774
|
+
let cancelled = false;
|
|
775
|
+
|
|
776
|
+
function isMovable(id: ColumnId): boolean {
|
|
777
|
+
return COLUMNS.find((c) => c.id === id)?.hideable === true;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function onHeadPointerDown(id: ColumnId, ev: PointerEvent) {
|
|
781
|
+
if (ev.button !== 0 || !isMovable(id)) return;
|
|
782
|
+
dragState = { id, startX: ev.clientX, armed: false };
|
|
783
|
+
window.addEventListener('pointermove', onHeadPointerMove);
|
|
784
|
+
window.addEventListener('pointerup', onHeadPointerUp);
|
|
785
|
+
window.addEventListener('keydown', onDragKey, true);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function onHeadPointerMove(ev: PointerEvent) {
|
|
789
|
+
if (!dragState) return;
|
|
790
|
+
if (!dragState.armed) {
|
|
791
|
+
if (Math.abs(ev.clientX - dragState.startX) < DRAG_SLOP) return;
|
|
792
|
+
dragState.armed = true;
|
|
793
|
+
dragCol.value = dragState.id;
|
|
794
|
+
}
|
|
795
|
+
/* Where the pointer is, in terms of the drawn header cells. The insertion
|
|
796
|
+
* point is the gap nearest the cursor, and it is ONE number — the marker
|
|
797
|
+
* draws it and the drop applies it, so the picture cannot promise a landing
|
|
798
|
+
* the drop does not honour. */
|
|
799
|
+
const head = listEl.value?.querySelector('.fe-list__head');
|
|
800
|
+
if (!head) return;
|
|
801
|
+
const cells = [...head.querySelectorAll('.fe-list__col')] as HTMLElement[];
|
|
802
|
+
/* The first two cells are the tick and Name, the last is the row menu; only
|
|
803
|
+
the ones between are positions a column can take. */
|
|
804
|
+
const movable = cells.slice(2, 2 + visibleCols.value.length);
|
|
805
|
+
let idx = movable.length;
|
|
806
|
+
for (let i = 0; i < movable.length; i++) {
|
|
807
|
+
const box = movable[i].getBoundingClientRect();
|
|
808
|
+
if (ev.clientX < box.left + box.width / 2) {
|
|
809
|
+
idx = i;
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
233
812
|
}
|
|
813
|
+
dropAt.value = idx;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function onHeadPointerUp() {
|
|
817
|
+
const st = dragState;
|
|
818
|
+
const target = dropAt.value;
|
|
819
|
+
endDrag();
|
|
820
|
+
/* ⚠ `cancelled` is checked as well as `armed`: Escape clears `dragState`,
|
|
821
|
+
* but a pointerup already queued behind it must not be able to complete the
|
|
822
|
+
* drop the person just abandoned. */
|
|
823
|
+
if (!st?.armed || cancelled) {
|
|
824
|
+
cancelled = false;
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
dragEndedAt = Date.now();
|
|
828
|
+
if (target == null) return;
|
|
829
|
+
/* Translate a position among the DRAWN columns into a position in the full
|
|
830
|
+
* stored order: a column shed for want of room still has a place in the
|
|
831
|
+
* sequence, and dropping between two visible columns must not silently
|
|
832
|
+
* reshuffle the one hiding between them. */
|
|
833
|
+
const drawn = visibleCols.value.filter((id) => id !== st.id);
|
|
834
|
+
const before = drawn[Math.min(target, drawn.length)];
|
|
835
|
+
const full = columnOrder();
|
|
836
|
+
moveColumn(st.id, before ? full.indexOf(before) : full.length);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function onDragKey(ev: KeyboardEvent) {
|
|
840
|
+
if (ev.key !== 'Escape') return;
|
|
841
|
+
/* Cancel: the drop is abandoned and nothing is written. The click is still
|
|
842
|
+
* swallowed, because the pointer is mid-gesture and releasing it over a
|
|
843
|
+
* header would otherwise sort the very column the person was rescuing. */
|
|
844
|
+
ev.stopPropagation();
|
|
845
|
+
const armed = dragState?.armed === true;
|
|
846
|
+
endDrag();
|
|
847
|
+
if (armed) {
|
|
848
|
+
cancelled = true;
|
|
849
|
+
dragEndedAt = Date.now();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function endDrag() {
|
|
854
|
+
dragState = null;
|
|
855
|
+
dragCol.value = null;
|
|
856
|
+
dropAt.value = null;
|
|
857
|
+
window.removeEventListener('pointermove', onHeadPointerMove);
|
|
858
|
+
window.removeEventListener('pointerup', onHeadPointerUp);
|
|
859
|
+
window.removeEventListener('keydown', onDragKey, true);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** Capture phase, on the header row: a click that ended a drag never reaches
|
|
863
|
+
* the sort button underneath it. */
|
|
864
|
+
function onHeadClickCapture(ev: MouseEvent) {
|
|
865
|
+
if (Date.now() - dragEndedAt > CLICK_SWALLOW_MS) return;
|
|
866
|
+
dragEndedAt = 0;
|
|
867
|
+
ev.preventDefault();
|
|
868
|
+
ev.stopPropagation();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ── the column menu ───────────────────────────────────────────────────
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* A small settings popover, opened by the header's own ⋮ or by right-clicking
|
|
875
|
+
* the header — the two gestures the owner named.
|
|
876
|
+
*
|
|
877
|
+
* ⚠ Not `ContextMenu`, and that is not a second menu in the sense filex lesson
|
|
878
|
+
* #67 warns about: nothing here is an action on a file, so there is no action
|
|
879
|
+
* list to drift out of step with the row menu's. It is a panel of checkboxes,
|
|
880
|
+
* which a menu cannot be — a menu closes on every pick, so turning four
|
|
881
|
+
* columns off would be four separate right-clicks.
|
|
882
|
+
*/
|
|
883
|
+
const colMenuOpen = ref(false);
|
|
884
|
+
const colMenuX = ref(0);
|
|
885
|
+
const colMenuY = ref(0);
|
|
886
|
+
const colMenuEl = ref<HTMLElement | null>(null);
|
|
887
|
+
|
|
888
|
+
const prefersDark = ref(false);
|
|
889
|
+
onMounted(() => {
|
|
234
890
|
try {
|
|
235
|
-
|
|
236
|
-
SORT_LS_KEY,
|
|
237
|
-
JSON.stringify({ key: sortKey.value, dir: sortDir.value }),
|
|
238
|
-
);
|
|
891
|
+
prefersDark.value = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
|
239
892
|
} catch {
|
|
240
|
-
/*
|
|
893
|
+
/* jsdom / no matchMedia */
|
|
241
894
|
}
|
|
895
|
+
});
|
|
896
|
+
const colMenuThemeClass = computed(
|
|
897
|
+
() => `fe-ctx-backdrop--theme-${props.theme || 'auto'}`,
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
/** Everything the person may toggle here: the candidates, minus the ones that
|
|
901
|
+
* are not theirs to hide (the star follows the Starred view's own rule). */
|
|
902
|
+
const menuCols = computed(() =>
|
|
903
|
+
/* In the person's OWN order, so this list reads as the list on screen — a
|
|
904
|
+
settings panel that disagrees with the thing it configures is how someone
|
|
905
|
+
ends up nudging the wrong column left. */
|
|
906
|
+
columnOrder()
|
|
907
|
+
.filter((id) => COLUMNS.find((c) => c.id === id)?.hideable && candidateCols.value.includes(id))
|
|
908
|
+
/* ⚠ No `squeezed` flag any more, and no "no room" note beside a ticked
|
|
909
|
+
row. It existed because a column could be ON and still not drawn, for
|
|
910
|
+
want of width; nothing is dropped for width now, so a ticked column is
|
|
911
|
+
on screen — possibly past the right edge, where the table scrolls to
|
|
912
|
+
reach it. A note that can no longer be true is worse than no note. */
|
|
913
|
+
.map((id) => ({
|
|
914
|
+
id,
|
|
915
|
+
label: colLabel(id),
|
|
916
|
+
on: !columnHidden(id),
|
|
917
|
+
canLeft: canMoveColumn(id, -1),
|
|
918
|
+
canRight: canMoveColumn(id, 1),
|
|
919
|
+
})),
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
/** The same move the drag performs, reachable from the keyboard. A reorder
|
|
923
|
+
* only a mouse can do is a reorder half the people using this cannot do. */
|
|
924
|
+
function nudgeCol(id: ColumnId, delta: -1 | 1) {
|
|
925
|
+
moveColumnBy(id, delta);
|
|
242
926
|
}
|
|
243
927
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
928
|
+
const canResetCols = computed(() => columnsCustomised());
|
|
929
|
+
const rememberedFolders = computed(() => rememberedCount());
|
|
930
|
+
const thisFolderRemembered = computed(() => !!props.folderKey && folderIsRemembered(props.folderKey));
|
|
931
|
+
|
|
932
|
+
async function openColMenu(ev: MouseEvent) {
|
|
933
|
+
ev.preventDefault();
|
|
934
|
+
ev.stopPropagation();
|
|
935
|
+
const el = ev.currentTarget as HTMLElement | null;
|
|
936
|
+
const box = el?.getBoundingClientRect();
|
|
937
|
+
/* Anchored to the BUTTON's corner when it came from the ⋮, to the pointer
|
|
938
|
+
* when it came from a right-click — the two gestures mean different things
|
|
939
|
+
* about where the person is looking. */
|
|
940
|
+
colMenuX.value = ev.clientX || (box ? box.left : 0);
|
|
941
|
+
colMenuY.value = ev.clientY || (box ? box.bottom : 0);
|
|
942
|
+
colMenuOpen.value = true;
|
|
943
|
+
await nextTick();
|
|
944
|
+
clampColMenu();
|
|
945
|
+
colMenuEl.value?.focus();
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** Keep it on screen. Opened from a header at the right edge it would
|
|
949
|
+
* otherwise hang off the window with no way to reach the last row. */
|
|
950
|
+
function clampColMenu() {
|
|
951
|
+
const el = colMenuEl.value;
|
|
952
|
+
if (!el) return;
|
|
953
|
+
const box = el.getBoundingClientRect();
|
|
954
|
+
const pad = 8;
|
|
955
|
+
if (box.right > window.innerWidth - pad) {
|
|
956
|
+
colMenuX.value = Math.max(pad, window.innerWidth - pad - box.width);
|
|
957
|
+
}
|
|
958
|
+
if (box.bottom > window.innerHeight - pad) {
|
|
959
|
+
colMenuY.value = Math.max(pad, window.innerHeight - pad - box.height);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function closeColMenu() {
|
|
964
|
+
colMenuOpen.value = false;
|
|
247
965
|
}
|
|
248
966
|
|
|
249
|
-
function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
967
|
+
function toggleCol(id: ColumnId) {
|
|
968
|
+
setColumnHidden(id, !columnHidden(id));
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function onResetColumns() {
|
|
972
|
+
resetColumns();
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* "Apply to all folders" — the escape hatch.
|
|
977
|
+
*
|
|
978
|
+
* ⚠ It only FORGETS. It does not need to write anything, because the global
|
|
979
|
+
* default is already this folder's setup: every deliberate change writes both
|
|
980
|
+
* the folder's memory and the global preference (`filex.view-mode`,
|
|
981
|
+
* `filex.list-sort`), so throwing the per-folder map away leaves exactly one
|
|
982
|
+
* answer standing — the one on screen. That property is the reason the rule
|
|
983
|
+
* fits in a sentence.
|
|
984
|
+
*/
|
|
985
|
+
function onApplyToAll() {
|
|
986
|
+
forgetAllFolders();
|
|
987
|
+
closeColMenu();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function onForgetFolder() {
|
|
991
|
+
if (props.folderKey) forgetFolder(props.folderKey);
|
|
992
|
+
closeColMenu();
|
|
253
993
|
}
|
|
254
994
|
|
|
255
995
|
const rows = computed(() => {
|
|
256
|
-
const key = sortKey.value;
|
|
257
|
-
if (!key) return props.files;
|
|
258
996
|
// Virtual rows (.trash / storage drives) stay pinned on top in their
|
|
259
997
|
// original order; only real entries take part in the sort.
|
|
260
998
|
const pinned: FileNode[] = [];
|
|
261
999
|
const rest: FileNode[] = [];
|
|
262
1000
|
for (const n of props.files) (isPinnedSpecial(n) ? pinned : rest).push(n);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const am = modifiedMs(a);
|
|
273
|
-
const bm = modifiedMs(b);
|
|
274
|
-
// Undated rows go last in both directions so date groups stay clean.
|
|
275
|
-
if (am == null && bm == null) return 0;
|
|
276
|
-
if (am == null) return 1;
|
|
277
|
-
if (bm == null) return -1;
|
|
278
|
-
return dir * (am - bm);
|
|
279
|
-
});
|
|
280
|
-
return [...pinned, ...rest];
|
|
1001
|
+
|
|
1002
|
+
/* The listing's order, from the one place that owns the rule
|
|
1003
|
+
* (`lib/sortOrder.compareInOrder`). An ordinary folder: folders first, then
|
|
1004
|
+
* the active key. A ranked answer: folders first and NOTHING else, which is
|
|
1005
|
+
* byte-for-byte the pass GridView already makes — so the two views cannot
|
|
1006
|
+
* disagree in either mode. Array#sort is stable, so ties keep the order the
|
|
1007
|
+
* backend answered in, and in `relevance` every pair of same-kind rows is a
|
|
1008
|
+
* tie: that stability IS how the server's ranking survives this line. */
|
|
1009
|
+
return [...pinned, ...rest.sort(sort.compareInOrder(props.order ?? 'sort'))];
|
|
281
1010
|
});
|
|
282
1011
|
|
|
283
1012
|
// ------------------------------------------------------------------
|
|
284
1013
|
// Date grouping — only while sorted by modified date. Rows are split
|
|
285
1014
|
// into contiguous segments so the row markup below stays untouched.
|
|
1015
|
+
//
|
|
1016
|
+
// ⚠⚠ The LADDER and the "is a heading honest here" rule both moved to
|
|
1017
|
+
// `lib/dateGroups`, because the grid and the gallery draw the same headings
|
|
1018
|
+
// now and a second copy of either would drift the first time a rung changed.
|
|
1019
|
+
// This file keeps only what is its own: which rows are aside from the dates.
|
|
286
1020
|
// ------------------------------------------------------------------
|
|
287
1021
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
1022
|
+
/* zaman:z1 — the headings answer "what day is this row from", so they read the
|
|
1023
|
+
* same clock the cell beside them prints (composables/useLocale → lib/timezone
|
|
1024
|
+
* → lib/dateGroups). They used to ask the DEVICE, so for a viewer set to UTC a
|
|
1025
|
+
* file touched at 01:00 Istanbul sat under "Today" in the group header and
|
|
1026
|
+
* printed yesterday's date in its own cell, one line apart. */
|
|
1027
|
+
const groupLabels = computed(() => ({ t, formatMonthYear, zonedYearMonth }));
|
|
294
1028
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
year: 'numeric',
|
|
300
|
-
}),
|
|
1029
|
+
watch(
|
|
1030
|
+
rows,
|
|
1031
|
+
(list) => emit('display-order', list),
|
|
1032
|
+
{ immediate: true },
|
|
301
1033
|
);
|
|
302
1034
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if (
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
if (
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
1035
|
+
/* ── Owner ────────────────────────────────────────────────────────────────
|
|
1036
|
+
*
|
|
1037
|
+
* Three answers, and only three: your own name is "You", a row nobody put here
|
|
1038
|
+
* through filex is "System", and anything else is the account's display name.
|
|
1039
|
+
*
|
|
1040
|
+
* ⚠ "System" is not a placeholder for missing data — it is the true answer for
|
|
1041
|
+
* a file the scanner found in the bucket, and most rows on a mounted storage
|
|
1042
|
+
* are exactly that. Leaving the cell blank there would read as "we failed to
|
|
1043
|
+
* look it up", which is a different and false statement. */
|
|
1044
|
+
function ownerLabel(n: FileNode): string {
|
|
1045
|
+
if (ownedByViewer(n)) return t('owner.you');
|
|
1046
|
+
if (ownerIdOf(n) === null) return t('owner.system');
|
|
1047
|
+
return ownerNameOf(n) || t('owner.unknown');
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** The hover text. It carries the two facts the column has no room for: that
|
|
1051
|
+
* the bytes were handed in by an anonymous visitor through a drop link, and
|
|
1052
|
+
* who touched the thing last when that is not the owner. */
|
|
1053
|
+
function ownerTitle(n: FileNode): string {
|
|
1054
|
+
const parts = [ownerLabel(n)];
|
|
1055
|
+
if (arrivedFromOutside(n)) parts.push(t('owner.external'));
|
|
1056
|
+
const actor = (n as Record<string, unknown>).last_actor_name;
|
|
1057
|
+
const actorSelf = (n as Record<string, unknown>).last_actor_self === true;
|
|
1058
|
+
const actorId = (n as Record<string, unknown>).last_actor_id;
|
|
1059
|
+
if (actorSelf || (typeof actor === 'string' && actor)) {
|
|
1060
|
+
const who = actorSelf ? t('owner.you') : String(actor);
|
|
1061
|
+
if (who !== parts[0]) parts.push(t('owner.last_actor', { who }));
|
|
1062
|
+
} else if (actorId === undefined && ownerIdOf(n) !== null) {
|
|
1063
|
+
// An owned row whose last change came from outside filex.
|
|
1064
|
+
parts.push(t('owner.last_actor', { who: t('owner.system') }));
|
|
333
1065
|
}
|
|
334
|
-
return
|
|
335
|
-
}
|
|
1066
|
+
return parts.join(' · ');
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const segments = computed<DateRun<FileNode>[]>(() =>
|
|
1070
|
+
groupByDate(rows.value, {
|
|
1071
|
+
/* ⚠ `ranked` is half of this and not a detail: over a search's ranked
|
|
1072
|
+
* answer the key is still `modified` (nothing cleared it), the listing
|
|
1073
|
+
* just is not obeying it — see `lib/dateGroups.groupingActive`. */
|
|
1074
|
+
active: groupingActive(sortKey.value, props.order),
|
|
1075
|
+
dateOf: (n) => toDate(n.last_modified),
|
|
1076
|
+
/* ⚠ Folders are their OWN run, above the first date heading, and that is
|
|
1077
|
+
* what lets "folders before files" hold while sorted by date. The rule
|
|
1078
|
+
* used to be softened instead — rows sorted by date, folders floating to
|
|
1079
|
+
* the top of each bucket — because hoisting them all would have split a
|
|
1080
|
+
* bucket in two and drawn "Today" twice, once over the folders and once
|
|
1081
|
+
* over the files. A run of their own removes that reason, and a folder has
|
|
1082
|
+
* no meaningful modification date to file under anyway. */
|
|
1083
|
+
aside: (n) =>
|
|
1084
|
+
isPinnedSpecial(n) ? { id: 'pinned' } : n.type === 'dir' ? { id: 'dirs' } : null,
|
|
1085
|
+
labels: groupLabels.value,
|
|
1086
|
+
}).runs,
|
|
1087
|
+
);
|
|
336
1088
|
</script>
|
|
337
1089
|
|
|
338
1090
|
<template>
|
|
339
1091
|
<!-- wiring:c4 — grid semantics: container role + localized labels; rows
|
|
340
1092
|
carry aria-selected below. Structure/layout untouched. -->
|
|
341
1093
|
<div
|
|
1094
|
+
ref="listEl"
|
|
342
1095
|
class="fe-list"
|
|
343
|
-
:class="{
|
|
1096
|
+
:class="{
|
|
1097
|
+
'is-loading': loading,
|
|
1098
|
+
'has-star-col': !!apiBase || apiBase === '',
|
|
1099
|
+
'is-scrolled-x': scrolledX,
|
|
1100
|
+
}"
|
|
344
1101
|
role="grid"
|
|
345
1102
|
:aria-label="t('list.aria')"
|
|
346
1103
|
:aria-busy="loading ? 'true' : undefined"
|
|
1104
|
+
@scroll.passive="onListScroll"
|
|
347
1105
|
>
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
1106
|
+
<!-- tablo:t2 — every width is INLINE, computed from the person's own
|
|
1107
|
+
columns and, until they touch one, from the pane (lib/viewPrefs
|
|
1108
|
+
.tableLayout). The head and the rows carry the SAME width object, so a
|
|
1109
|
+
table scrolled sideways cannot drift out of line with its header. -->
|
|
1110
|
+
<div
|
|
1111
|
+
class="fe-list__head"
|
|
1112
|
+
role="row"
|
|
1113
|
+
:class="{ 'is-dragging-col': !!dragCol }"
|
|
1114
|
+
:style="tableStyle"
|
|
1115
|
+
@contextmenu="openColMenu"
|
|
1116
|
+
@click.capture="onHeadClickCapture"
|
|
1117
|
+
>
|
|
1118
|
+
<!-- gorunum:v1 — the checkbox column's header stays empty on purpose: a
|
|
1119
|
+
select-all tick here is a real affordance with real consequences
|
|
1120
|
+
(Delete acts on the selection) and the spec does not call for one. -->
|
|
1121
|
+
<div class="fe-list__col fe-list__col--check" role="columnheader"></div>
|
|
1122
|
+
<div
|
|
1123
|
+
class="fe-list__col fe-list__col--name"
|
|
1124
|
+
role="columnheader"
|
|
1125
|
+
:style="colStyle('name')"
|
|
1126
|
+
:aria-sort="ariaSort('name')"
|
|
1127
|
+
>
|
|
1128
|
+
<button type="button" class="fe-list__sort" :disabled="ranked" :title="sortTitle" @click="toggleSort('name')">
|
|
352
1129
|
{{ t('col.name') }}
|
|
353
|
-
<span v-if="sortKey ===
|
|
1130
|
+
<span v-if="!ranked && sortKey ==='name'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
|
|
354
1131
|
</button>
|
|
1132
|
+
<!-- Name's own handle. It used to have none, because Name was the
|
|
1133
|
+
flexible track and "dragging Name" could only mean dragging
|
|
1134
|
+
everything else; it has a width of its own now, so it drags like
|
|
1135
|
+
any other column — in both directions. -->
|
|
1136
|
+
<span
|
|
1137
|
+
class="fe-list__resize"
|
|
1138
|
+
role="separator"
|
|
1139
|
+
aria-orientation="vertical"
|
|
1140
|
+
tabindex="0"
|
|
1141
|
+
:aria-label="t('cols.resize', { col: t('col.name') })"
|
|
1142
|
+
:title="t('cols.resize', { col: t('col.name') })"
|
|
1143
|
+
@pointerdown="onResizeStart('name', $event)"
|
|
1144
|
+
@dblclick.stop="onResizeReset('name')"
|
|
1145
|
+
@keydown="onResizeKey('name', $event)"
|
|
1146
|
+
></span>
|
|
355
1147
|
</div>
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
1148
|
+
<!-- surucu:d1-sort — Type / Modified / Size sort; Location and the star
|
|
1149
|
+
do not, because there is no SortKey for either and inventing one
|
|
1150
|
+
would put a word in the Sort-by menu that means nothing in nine
|
|
1151
|
+
listings out of ten. Owner is the same case: the People chip is how
|
|
1152
|
+
you narrow by person. -->
|
|
1153
|
+
<div
|
|
1154
|
+
v-for="(id, ci) in visibleCols"
|
|
1155
|
+
:key="id"
|
|
1156
|
+
:class="[
|
|
1157
|
+
colClass(id),
|
|
1158
|
+
{
|
|
1159
|
+
'is-col-dragging': dragCol === id,
|
|
1160
|
+
'is-drop-before': dropAt === ci && !!dragCol && dragCol !== id,
|
|
1161
|
+
'is-drop-after': dropAt === visibleCols.length && ci === visibleCols.length - 1 && !!dragCol,
|
|
1162
|
+
'is-movable': isMovable(id),
|
|
1163
|
+
},
|
|
1164
|
+
]"
|
|
1165
|
+
role="columnheader"
|
|
1166
|
+
:style="colStyle(id)"
|
|
1167
|
+
:aria-sort="SORT_OF[id] ? ariaSort(SORT_OF[id]!) : undefined"
|
|
1168
|
+
:aria-label="id === 'star' ? t('col.star') : undefined"
|
|
1169
|
+
@pointerdown="onHeadPointerDown(id, $event)"
|
|
1170
|
+
>
|
|
1171
|
+
<button
|
|
1172
|
+
v-if="SORT_OF[id]"
|
|
1173
|
+
type="button"
|
|
1174
|
+
class="fe-list__sort"
|
|
1175
|
+
:disabled="ranked"
|
|
1176
|
+
:title="sortTitle"
|
|
1177
|
+
@click="toggleSort(SORT_OF[id]!)"
|
|
1178
|
+
>
|
|
1179
|
+
{{ colLabel(id) }}
|
|
1180
|
+
<span v-if="!ranked && sortKey === SORT_OF[id]" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
|
|
360
1181
|
</button>
|
|
1182
|
+
<span v-else-if="id !== 'star'" class="fe-list__head-label">{{ colLabel(id) }}</span>
|
|
1183
|
+
<!-- The drag handle straddles the divider on this column's trailing
|
|
1184
|
+
edge. `role="separator"` and focusable, so the arrow keys move it
|
|
1185
|
+
too — a handle you can only reach with a mouse is a handle half
|
|
1186
|
+
the people using this cannot reach at all. -->
|
|
1187
|
+
<span
|
|
1188
|
+
v-if="isResizable(id)"
|
|
1189
|
+
class="fe-list__resize"
|
|
1190
|
+
role="separator"
|
|
1191
|
+
aria-orientation="vertical"
|
|
1192
|
+
tabindex="0"
|
|
1193
|
+
:aria-label="t('cols.resize', { col: colLabel(id) })"
|
|
1194
|
+
:title="t('cols.resize', { col: colLabel(id) })"
|
|
1195
|
+
@pointerdown="onResizeStart(id, $event)"
|
|
1196
|
+
@dblclick.stop="onResizeReset(id)"
|
|
1197
|
+
@keydown="onResizeKey(id, $event)"
|
|
1198
|
+
></span>
|
|
361
1199
|
</div>
|
|
362
|
-
<div class="fe-list__col fe-list__col--
|
|
363
|
-
<button
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
1200
|
+
<div class="fe-list__col fe-list__col--menu" role="columnheader">
|
|
1201
|
+
<button
|
|
1202
|
+
type="button"
|
|
1203
|
+
class="fe-list__menu fe-list__colmenu-btn"
|
|
1204
|
+
:title="t('cols.menu')"
|
|
1205
|
+
:aria-label="t('cols.menu')"
|
|
1206
|
+
aria-haspopup="dialog"
|
|
1207
|
+
:aria-expanded="colMenuOpen ? 'true' : 'false'"
|
|
1208
|
+
@click="openColMenu"
|
|
1209
|
+
><span aria-hidden="true">⋮</span></button>
|
|
367
1210
|
</div>
|
|
368
1211
|
</div>
|
|
369
1212
|
<div class="fe-list__body" role="rowgroup">
|
|
370
1213
|
<template v-for="seg in segments" :key="seg.id">
|
|
371
1214
|
<div v-if="seg.label" class="fe-list__group" role="presentation">{{ seg.label }}</div>
|
|
372
1215
|
<div
|
|
373
|
-
v-for="n in seg.
|
|
1216
|
+
v-for="n in seg.items"
|
|
374
1217
|
:key="n.path"
|
|
375
1218
|
class="fe-list__row"
|
|
376
1219
|
:class="{
|
|
@@ -382,6 +1225,7 @@ const segments = computed<Segment[]>(() => {
|
|
|
382
1225
|
}"
|
|
383
1226
|
role="row"
|
|
384
1227
|
tabindex="0"
|
|
1228
|
+
:style="tableStyle /* tablo:t2 — the head's own width, so the two cannot drift */"
|
|
385
1229
|
:aria-selected="isSelected(n) ? 'true' : 'false'"
|
|
386
1230
|
:aria-label="nodeDisplayName(n) /* wiring:c4 */"
|
|
387
1231
|
:data-fe-path="n.path /* wiring:d1 — middle-click open-in-new-tab delegation */"
|
|
@@ -397,25 +1241,61 @@ const segments = computed<Segment[]>(() => {
|
|
|
397
1241
|
@touchend="cancelPress"
|
|
398
1242
|
@touchmove="cancelPress"
|
|
399
1243
|
>
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
1244
|
+
<!-- gorunum:v1 — the tick drives the SAME selection the row click
|
|
1245
|
+
does (onCheckClick → click-row → useSelection.click), so shift
|
|
1246
|
+
ranges and ctrl toggles are one implementation, not two.
|
|
1247
|
+
⚠ A button with role="checkbox", NOT an <input type="checkbox">.
|
|
1248
|
+
The input owns a `checked` state of its own: the browser flips it
|
|
1249
|
+
before the click handler runs and, if the default is prevented,
|
|
1250
|
+
flips it back AFTER Vue has patched — so the row went selected
|
|
1251
|
+
while the box stayed empty (measured: rowSelected=true,
|
|
1252
|
+
domChecked=false). Drawing the state from the selection leaves
|
|
1253
|
+
nothing to drift. Space and Enter still tick it, because a button
|
|
1254
|
+
fires `click` for both. -->
|
|
1255
|
+
<div class="fe-list__col fe-list__col--check" role="gridcell" @click.stop @dblclick.stop>
|
|
1256
|
+
<button
|
|
1257
|
+
type="button"
|
|
1258
|
+
class="fe-list__check"
|
|
1259
|
+
:class="{ 'is-on': isSelected(n) }"
|
|
1260
|
+
role="checkbox"
|
|
1261
|
+
:aria-checked="isSelected(n) ? 'true' : 'false'"
|
|
1262
|
+
:aria-label="nodeDisplayName(n)"
|
|
1263
|
+
:title="nodeDisplayName(n)"
|
|
1264
|
+
@click.stop="onCheckClick(n, $event)"
|
|
1265
|
+
></button>
|
|
412
1266
|
</div>
|
|
413
|
-
<div class="fe-list__col fe-list__col--name" role="gridcell">
|
|
414
|
-
|
|
1267
|
+
<div class="fe-list__col fe-list__col--name" role="gridcell" :style="colStyle('name')">
|
|
1268
|
+
<!-- ikon:emoji — an encrypted folder is still a FOLDER: it keeps the
|
|
1269
|
+
folder's shape and colour with the padlock cut out of it, and the
|
|
1270
|
+
span carries the name the bare lock emoji never had. One definition
|
|
1271
|
+
for all three views, in lib/fileIcons. -->
|
|
415
1272
|
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
416
|
-
<span
|
|
1273
|
+
<span
|
|
1274
|
+
v-if="isEncryptedFolder(n)"
|
|
1275
|
+
class="fe-list__icon fe-list__icon--svg"
|
|
1276
|
+
role="img"
|
|
1277
|
+
:aria-label="t('e2e.badge')"
|
|
1278
|
+
v-html="encryptedFolderTile()"
|
|
1279
|
+
></span>
|
|
1280
|
+
<!-- tablo:t1 — the real thumbnail, at the tile's own size so the row
|
|
1281
|
+
height does not move. `draggable="false"` for the reason the
|
|
1282
|
+
grid gives: dragging an <img> puts a 'Files' MIME on the
|
|
1283
|
+
dataTransfer and the parent's upload handler re-uploads the
|
|
1284
|
+
thing you were only moving. -->
|
|
1285
|
+
<img
|
|
1286
|
+
v-else-if="thumbOf(n)"
|
|
1287
|
+
class="fe-list__icon fe-list__icon--img"
|
|
1288
|
+
:class="{ 'fe-thumb--page': drawsAsPage(n) }"
|
|
1289
|
+
:src="thumbOf(n)!"
|
|
1290
|
+
alt=""
|
|
1291
|
+
aria-hidden="true"
|
|
1292
|
+
loading="lazy"
|
|
1293
|
+
draggable="false"
|
|
1294
|
+
/>
|
|
1295
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
1296
|
+
<span v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconTile(n)"></span>
|
|
417
1297
|
<div class="fe-list__name-wrap">
|
|
418
|
-
<span class="fe-list__name" :title="n
|
|
1298
|
+
<span class="fe-list__name" :title="nameTitle(n)">
|
|
419
1299
|
{{ nodeDisplayName(n) }}
|
|
420
1300
|
<!-- bul:s3 — content-match badge -->
|
|
421
1301
|
<span v-if="rowInContent(n)" class="fe-list__badge">{{ t('search.in_content') }}</span>
|
|
@@ -427,11 +1307,11 @@ const segments = computed<Segment[]>(() => {
|
|
|
427
1307
|
:aria-label="t('keep.badge_' + keepBadgeFor(n))"
|
|
428
1308
|
>{{ keepGlyph(keepBadgeFor(n)!) }}</span>
|
|
429
1309
|
</span>
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
1310
|
+
<!-- tablo:t1 — the parent path used to be a second line HERE, and
|
|
1311
|
+
printed an em dash when a row had no sub-folder. That is the
|
|
1312
|
+
dash the owner reported under the filenames; it is a Location
|
|
1313
|
+
COLUMN now ("her değer kendi sütununda gözükmesi gerekmekte"),
|
|
1314
|
+
and a row at a storage's root simply has an empty cell. -->
|
|
435
1315
|
<!-- bul:s3 — content snippet («» → <mark> via TEXT segments, no innerHTML) -->
|
|
436
1316
|
<span v-if="rowSnippet(n)" class="fe-list__snippet">
|
|
437
1317
|
<template v-for="(seg, si) in snippetSegments(rowSnippet(n))" :key="si">
|
|
@@ -440,12 +1320,47 @@ const segments = computed<Segment[]>(() => {
|
|
|
440
1320
|
</template>
|
|
441
1321
|
</span>
|
|
442
1322
|
</div>
|
|
1323
|
+
<!-- gorunum:v1 — the kind, said in words beside the name. OUTSIDE
|
|
1324
|
+
`.fe-list__name`: the shot scripts read that element's
|
|
1325
|
+
textContent as the filename (e2e/shots/starstags.mjs), and a
|
|
1326
|
+
row whose name reads "Reports Folder" breaks them silently. -->
|
|
1327
|
+
<span v-if="isPlainDir(n)" class="fe-list__kind">{{ t('node.folder') }}</span>
|
|
443
1328
|
</div>
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
<
|
|
448
|
-
|
|
1329
|
+
<!-- tablo:t1 — one loop over the SAME list the header draws, so a
|
|
1330
|
+
column cannot exist in one and not the other, and the values land
|
|
1331
|
+
in the tracks the inline template allocated. -->
|
|
1332
|
+
<template v-for="id in visibleCols" :key="id">
|
|
1333
|
+
<div
|
|
1334
|
+
v-if="id === 'star'"
|
|
1335
|
+
class="fe-list__col fe-list__col--star"
|
|
1336
|
+
role="gridcell"
|
|
1337
|
+
:style="colStyle(id)"
|
|
1338
|
+
@click.stop
|
|
1339
|
+
>
|
|
1340
|
+
<StarButton
|
|
1341
|
+
v-if="starEnabled !== false && typeof n.id === 'number' && n.type === 'file'"
|
|
1342
|
+
:starred="!!starredIds?.has(n.id)"
|
|
1343
|
+
:node-id="n.id"
|
|
1344
|
+
:api-base="apiBase"
|
|
1345
|
+
:auth-headers="authHeaders"
|
|
1346
|
+
:auth-credentials="authCredentials"
|
|
1347
|
+
:locale="locale"
|
|
1348
|
+
compact
|
|
1349
|
+
@change="(val: boolean) => emit('star-change', n, val)"
|
|
1350
|
+
/>
|
|
1351
|
+
</div>
|
|
1352
|
+
<div v-else :class="colClass(id)" role="gridcell" :style="colStyle(id)" :title="cellTitle(id, n)">
|
|
1353
|
+
{{ cellText(id, n) }}
|
|
1354
|
+
</div>
|
|
1355
|
+
</template>
|
|
1356
|
+
<div class="fe-list__col fe-list__col--menu" role="gridcell" @click.stop @dblclick.stop>
|
|
1357
|
+
<button
|
|
1358
|
+
type="button"
|
|
1359
|
+
class="fe-list__menu"
|
|
1360
|
+
:title="t('toolbar.more')"
|
|
1361
|
+
:aria-label="t('toolbar.more')"
|
|
1362
|
+
@click="onRowMenu(n, $event)"
|
|
1363
|
+
><span aria-hidden="true">⋮</span></button>
|
|
449
1364
|
</div>
|
|
450
1365
|
</div>
|
|
451
1366
|
</template>
|
|
@@ -454,4 +1369,80 @@ const segments = computed<Segment[]>(() => {
|
|
|
454
1369
|
</div>
|
|
455
1370
|
</div>
|
|
456
1371
|
</div>
|
|
1372
|
+
<!-- tablo:t1 — the column menu. Teleported to <body> for the reason
|
|
1373
|
+
ContextMenu gives: inside the tree, an ancestor with `overflow: hidden`
|
|
1374
|
+
or its own transform clips or displaces it, and the header sits inside a
|
|
1375
|
+
scrolling region. It therefore carries the theme class `variables.css`
|
|
1376
|
+
re-applies dark values under, because the `.fe` ancestor that normally
|
|
1377
|
+
scopes the tokens is not above it any more. -->
|
|
1378
|
+
<Teleport to="body">
|
|
1379
|
+
<div
|
|
1380
|
+
v-if="colMenuOpen"
|
|
1381
|
+
class="fe-colmenu__backdrop"
|
|
1382
|
+
:class="colMenuThemeClass"
|
|
1383
|
+
:data-prefers-dark="prefersDark ? '1' : '0'"
|
|
1384
|
+
@click="closeColMenu"
|
|
1385
|
+
@contextmenu.prevent="closeColMenu"
|
|
1386
|
+
>
|
|
1387
|
+
<div
|
|
1388
|
+
ref="colMenuEl"
|
|
1389
|
+
class="fe-colmenu"
|
|
1390
|
+
role="dialog"
|
|
1391
|
+
tabindex="-1"
|
|
1392
|
+
:aria-label="t('cols.menu')"
|
|
1393
|
+
:style="{ top: colMenuY + 'px', left: colMenuX + 'px' }"
|
|
1394
|
+
@click.stop
|
|
1395
|
+
@keydown.esc.stop="closeColMenu"
|
|
1396
|
+
>
|
|
1397
|
+
<p class="fe-colmenu__title">{{ t('cols.title') }}</p>
|
|
1398
|
+
<div v-for="c in menuCols" :key="c.id" class="fe-colmenu__line">
|
|
1399
|
+
<button
|
|
1400
|
+
type="button"
|
|
1401
|
+
class="fe-colmenu__row"
|
|
1402
|
+
role="checkbox"
|
|
1403
|
+
:aria-checked="c.on ? 'true' : 'false'"
|
|
1404
|
+
@click="toggleCol(c.id)"
|
|
1405
|
+
>
|
|
1406
|
+
<span class="fe-list__check fe-colmenu__tick" :class="{ 'is-on': c.on }" aria-hidden="true"></span>
|
|
1407
|
+
<span class="fe-colmenu__label">{{ c.label }}</span>
|
|
1408
|
+
</button>
|
|
1409
|
+
<!-- The drag, reachable from a keyboard. Same move, same model — a
|
|
1410
|
+
reorder only a mouse can perform is one half the people using
|
|
1411
|
+
this cannot perform at all. -->
|
|
1412
|
+
<button
|
|
1413
|
+
type="button"
|
|
1414
|
+
class="fe-colmenu__nudge"
|
|
1415
|
+
:disabled="!c.canLeft"
|
|
1416
|
+
:title="t('cols.move_left', { col: c.label })"
|
|
1417
|
+
:aria-label="t('cols.move_left', { col: c.label })"
|
|
1418
|
+
@click="nudgeCol(c.id, -1)"
|
|
1419
|
+
><span aria-hidden="true">←</span></button>
|
|
1420
|
+
<button
|
|
1421
|
+
type="button"
|
|
1422
|
+
class="fe-colmenu__nudge"
|
|
1423
|
+
:disabled="!c.canRight"
|
|
1424
|
+
:title="t('cols.move_right', { col: c.label })"
|
|
1425
|
+
:aria-label="t('cols.move_right', { col: c.label })"
|
|
1426
|
+
@click="nudgeCol(c.id, 1)"
|
|
1427
|
+
><span aria-hidden="true">→</span></button>
|
|
1428
|
+
</div>
|
|
1429
|
+
<div v-if="canResetCols" class="fe-colmenu__sep" role="separator"></div>
|
|
1430
|
+
<button v-if="canResetCols" type="button" class="fe-colmenu__row" @click="onResetColumns">
|
|
1431
|
+
<span class="fe-colmenu__label">{{ t('cols.reset') }}</span>
|
|
1432
|
+
</button>
|
|
1433
|
+
<div v-if="thisFolderRemembered || rememberedFolders > 0" class="fe-colmenu__sep" role="separator"></div>
|
|
1434
|
+
<p v-if="thisFolderRemembered || rememberedFolders > 0" class="fe-colmenu__title">{{ t('cols.folder_title') }}</p>
|
|
1435
|
+
<!-- The rule, in one sentence, next to the two buttons that act on it.
|
|
1436
|
+
A per-folder memory nobody can explain is the difference between a
|
|
1437
|
+
feature and a haunting. -->
|
|
1438
|
+
<p v-if="thisFolderRemembered || rememberedFolders > 0" class="fe-colmenu__hint">{{ t('cols.folder_hint') }}</p>
|
|
1439
|
+
<button v-if="thisFolderRemembered" type="button" class="fe-colmenu__row" @click="onForgetFolder">
|
|
1440
|
+
<span class="fe-colmenu__label">{{ t('cols.forget_folder') }}</span>
|
|
1441
|
+
</button>
|
|
1442
|
+
<button v-if="rememberedFolders > 0" type="button" class="fe-colmenu__row" @click="onApplyToAll">
|
|
1443
|
+
<span class="fe-colmenu__label">{{ t('cols.apply_all', { count: rememberedFolders }) }}</span>
|
|
1444
|
+
</button>
|
|
1445
|
+
</div>
|
|
1446
|
+
</div>
|
|
1447
|
+
</Teleport>
|
|
457
1448
|
</template>
|