@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.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. 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 { fileIconSvg } from '../lib/fileIcons';
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 { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
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.mime_type === 'inode/storage';
298
+ return n.basename === '.trash' || isStorageRow(n);
170
299
  }
171
300
 
172
301
  function displayDate(ms: number | undefined): string {
173
- if (!ms) return '—';
174
- const d = new Date(ms * (ms < 1e12 ? 1000 : 1));
175
- return d.toLocaleString();
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
- function parentDir(path: string): string {
179
- const stripped = path.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
180
- const idx = stripped.lastIndexOf('/');
181
- if (idx === -1) return '';
182
- return stripped.slice(0, idx);
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 — local to the list view. Default (null) keeps the
200
- // backend order, i.e. exactly the pre-existing behavior.
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
- type SortKey = 'name' | 'size' | 'modified';
204
- const SORT_LS_KEY = 'filex.list-sort';
205
-
206
- const sortKey = ref<SortKey | null>(null);
207
- const sortDir = ref<'asc' | 'desc'>('asc');
208
- try {
209
- const raw = localStorage.getItem(SORT_LS_KEY);
210
- if (raw) {
211
- const p = JSON.parse(raw) as { key?: unknown; dir?: unknown };
212
- if (p.key === 'name' || p.key === 'size' || p.key === 'modified') sortKey.value = p.key;
213
- if (p.dir === 'asc' || p.dir === 'desc') sortDir.value = p.dir;
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
- // Dates default to newest-first, the others to ascending.
220
- function defaultDir(key: SortKey): 'asc' | 'desc' {
221
- return key === 'modified' ? 'desc' : 'asc';
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
- // Click cycle per column: default direction → flipped → back to backend order.
225
- function toggleSort(key: SortKey) {
226
- if (sortKey.value !== key) {
227
- sortKey.value = key;
228
- sortDir.value = defaultDir(key);
229
- } else if (sortDir.value === defaultDir(key)) {
230
- sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
231
- } else {
232
- sortKey.value = null;
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
- localStorage.setItem(
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
- /* quota */
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
- function ariaSort(key: SortKey): 'ascending' | 'descending' | 'none' {
245
- if (sortKey.value !== key) return 'none';
246
- return sortDir.value === 'asc' ? 'ascending' : 'descending';
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 modifiedMs(n: FileNode): number | null {
250
- const v = n.last_modified;
251
- if (!v) return null;
252
- return v * (v < 1e12 ? 1000 : 1);
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
- const dir = sortDir.value === 'asc' ? 1 : -1;
264
- rest.sort((a, b) => {
265
- if (key === 'name') {
266
- return (
267
- dir *
268
- a.basename.localeCompare(b.basename, undefined, { numeric: true, sensitivity: 'base' })
269
- );
270
- }
271
- if (key === 'size') return dir * ((a.size ?? -1) - (b.size ?? -1));
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
- interface Segment {
289
- id: string;
290
- /** null = no header (ungrouped mode, or the pinned virtual rows). */
291
- label: string | null;
292
- nodes: FileNode[];
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
- const monthFmt = computed(
296
- () =>
297
- new Intl.DateTimeFormat(props.locale === 'tr' ? 'tr-TR' : 'en-US', {
298
- month: 'long',
299
- year: 'numeric',
300
- }),
1029
+ watch(
1030
+ rows,
1031
+ (list) => emit('display-order', list),
1032
+ { immediate: true },
301
1033
  );
302
1034
 
303
- function bucketFor(ms: number | null): { id: string; label: string } {
304
- if (ms == null) return { id: 'none', label: t('group.no_date') };
305
- const d = new Date(ms);
306
- const now = new Date();
307
- const dayStart = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
308
- const today = dayStart(now);
309
- const day = dayStart(d);
310
- const DAY = 86_400_000;
311
- // Future stamps (clock skew) read best as "today".
312
- if (day >= today) return { id: 'today', label: t('group.today') };
313
- if (day === today - DAY) return { id: 'yesterday', label: t('group.yesterday') };
314
- if (day > today - 7 * DAY) return { id: 'week', label: t('group.this_week') };
315
- if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) {
316
- return { id: 'month', label: t('group.this_month') };
317
- }
318
- return { id: `m-${d.getFullYear()}-${d.getMonth()}`, label: monthFmt.value.format(d) };
319
- }
320
-
321
- const segments = computed<Segment[]>(() => {
322
- const list = rows.value;
323
- if (sortKey.value !== 'modified') return [{ id: 'all', label: null, nodes: list }];
324
- const out: Segment[] = [];
325
- let cur: Segment | null = null;
326
- for (const n of list) {
327
- const b = isPinnedSpecial(n) ? { id: 'pinned', label: null } : bucketFor(modifiedMs(n));
328
- if (!cur || cur.id !== b.id) {
329
- cur = { id: b.id, label: b.label, nodes: [] };
330
- out.push(cur);
331
- }
332
- cur.nodes.push(n);
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 out;
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="{ 'is-loading': loading, 'has-star-col': !!apiBase || apiBase === '' }"
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
- <div class="fe-list__head" role="row">
349
- <div class="fe-list__col fe-list__col--star" role="columnheader" :aria-label="t('col.star') /* wiring:c4 */"></div>
350
- <div class="fe-list__col fe-list__col--name" role="columnheader" :aria-sort="ariaSort('name')">
351
- <button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('name')">
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 === 'name'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
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
- <div class="fe-list__col fe-list__col--size" role="columnheader" :aria-sort="ariaSort('size')">
357
- <button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('size')">
358
- {{ t('col.size') }}
359
- <span v-if="sortKey === 'size'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
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--mod" role="columnheader" :aria-sort="ariaSort('modified')">
363
- <button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('modified')">
364
- {{ t('col.modified') }}
365
- <span v-if="sortKey === 'modified'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
366
- </button>
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">&#8942;</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.nodes"
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
- <div class="fe-list__col fe-list__col--star" role="gridcell" @click.stop>
401
- <StarButton
402
- v-if="starEnabled !== false && typeof n.id === 'number' && n.type === 'file'"
403
- :starred="!!starredIds?.has(n.id)"
404
- :node-id="n.id"
405
- :api-base="apiBase"
406
- :auth-headers="authHeaders"
407
- :auth-credentials="authCredentials"
408
- :locale="locale"
409
- compact
410
- @change="(val: boolean) => emit('star-change', n, val)"
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
- <span v-if="specialEmojiFor(n)" class="fe-list__icon" aria-hidden="true">{{ specialEmojiFor(n) }}</span>
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 v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconSvg(n)"></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.basename">
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
- <span
431
- v-if="showParentPath"
432
- class="fe-list__parent"
433
- :title="parentDir(n.path)"
434
- >{{ parentDir(n.path) || '—' }}</span>
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
- <div class="fe-list__col fe-list__col--size" role="gridcell">
445
- {{ formatSize(n.size) }}
446
- </div>
447
- <div class="fe-list__col fe-list__col--mod" role="gridcell">
448
- {{ displayDate(n.last_modified) }}
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">&#8942;</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">&#8592;</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">&#8594;</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>