@brftech/filex-core 0.40.0 → 0.41.1

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