@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
@@ -9,6 +9,8 @@
9
9
  // from the same rule. A surface that clamps differently would mean the same
10
10
  // product behaves two ways.
11
11
 
12
+ import { formatInstant } from '../composables/useLocale'; /* zaman:z1 */
13
+
12
14
  export interface ExpiryOption {
13
15
  /** Days; 0 = never. */
14
16
  v: number;
@@ -75,9 +77,17 @@ export function expiryInputMax(maxDays: number | undefined, now: Date = new Date
75
77
  export function validUntilLine(expiresAt: string | null | undefined, locale: 'tr' | 'en'): string {
76
78
  if (!expiresAt) return locale === 'tr' ? 'Bu bağlantının süresi yoktur.' : 'This link does not expire.';
77
79
  const d = new Date(expiresAt);
80
+ // zaman:z1 — the viewer's chosen clock, not the browser's. "Valid until
81
+ // 14:05" is a deadline, and a deadline printed in somebody else's zone is
82
+ // the worst kind of wrong: it looks actionable. Both halves of that — the
83
+ // zone AND the locale tag — come from `formatInstant`, which is the one
84
+ // place either is decided. This file used to pass the zone correctly and
85
+ // then map the tag itself, to 'en-GB', while useLocale mapped it to
86
+ // 'en-US': the same deadline was spelled "20 Sept 2026, 13:53" in the share
87
+ // dialog and "Sep 20, 2026, 1:53 PM" in the listing behind it.
78
88
  const when = Number.isNaN(d.getTime())
79
89
  ? expiresAt
80
- : d.toLocaleString(locale === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' });
90
+ : formatInstant(d, locale, { dateStyle: 'medium', timeStyle: 'short' });
81
91
  return locale === 'tr' ? `Bu bağlantı ${when} tarihine kadar geçerli.` : `This link is valid until ${when}.`;
82
92
  }
83
93
 
@@ -0,0 +1,459 @@
1
+ /**
2
+ * sortOrder — THE listing sort: one key, one direction, one comparator, for
3
+ * every surface that draws a folder's contents.
4
+ *
5
+ * ⚠⚠ Why this file exists at all. Before it, the sort lived as two private
6
+ * refs inside `ListView.vue` and the grid had none, so "sorted by size" was a
7
+ * fact about one component rather than about the listing: switching grid ⇄
8
+ * list re-ordered the rows under the user, and any second control that wanted
9
+ * to sort would have had to keep its own copy of the answer. That is the exact
10
+ * shape of filex lesson #67 — the grid and the list each holding a private
11
+ * copy of "folders first" and drifting apart — one level up.
12
+ *
13
+ * ⚠⚠ …and why it is no longer a module-level SINGLETON. A singleton answers
14
+ * "what is the sort" for the whole bundle, which is the right answer only
15
+ * while the bundle draws one listing. The split view draws two, side by side,
16
+ * and the owner's ruling (2026-09-13) is that the second pane is the first
17
+ * pane: it gets the same crumbs, the same filter row, the same view switcher
18
+ * and the same sort control. Two sort CONTROLS over one piece of state is a
19
+ * control that moves the listing the person is not looking at — measured the
20
+ * same day: with the singleton in place, choosing "Size ↓" in the right pane
21
+ * re-ordered the left one too.
22
+ *
23
+ * So the state is a STORE (`createSortStore`) and there is one per pane, while
24
+ * everything that is genuinely one answer stays module-level:
25
+ *
26
+ * · the RULES (`byActiveKey`, `compareNodes`, folders-first, the two click
27
+ * vocabularies) — written once, closed over each store;
28
+ * · the LOCALE the `type` key sorts in — one alphabet for the whole bundle;
29
+ * · the persisted GLOBAL DEFAULT (`filex.list-sort`) — "the last sort a
30
+ * person chose", which is one fact no matter which pane they chose it in.
31
+ *
32
+ * ⚠ Panes find their store by INJECTION (`provideSortStore` / `useSortStore`),
33
+ * not by a prop threaded through every view: `ListView` and `FilterBar` are
34
+ * two levels down and an embedder may mount either on its own. With no
35
+ * provider — a bare `<ListView>` in a test, a host that renders one listing —
36
+ * `useSortStore()` hands back the default store, which IS the old singleton,
37
+ * so nothing that worked before changes.
38
+ *
39
+ * ⚠ Two RULES this file owns, so no caller has to remember them:
40
+ *
41
+ * 1. Folders before files, in every key and BOTH directions. `compareNodes`
42
+ * applies `byFoldersFirst` as the primary comparator and never multiplies
43
+ * it by the direction — multiplying would make "Name ↓" mean "files
44
+ * first", i.e. the arrow would silently regroup the listing instead of
45
+ * reversing it. The key only decides the order WITHIN each group.
46
+ * 2. One vocabulary. A column header cycles (`chooseSortKey`: a new key
47
+ * arrives in its default direction, the active one flips) and a menu item
48
+ * jumps (`setSort`: a named destination never reverses under you). Both
49
+ * live here, so the two surfaces reach for the same words instead of each
50
+ * re-deriving what a click means.
51
+ *
52
+ * ⚠ The persisted shape is the one `ListView` already wrote (`filex.list-sort`,
53
+ * `{key, dir}`), so a user who had sorted a column keeps that sort. The old
54
+ * `key: null` — "leave the backend's order alone" — is NOT a key here; see
55
+ * `ListingOrder` below for where that state went and why it could not be one.
56
+ */
57
+ import { inject, provide, ref, type InjectionKey, type Ref } from 'vue';
58
+
59
+ import type { FileNode } from '../types/FileNode';
60
+ import type { LocaleCode } from '../types/ExplorerConfig';
61
+ import { messages } from '../locales';
62
+ import { typeLabelKey } from './fileIcons';
63
+ import { byFoldersFirst } from './listing';
64
+
65
+ export type SortKey = 'name' | 'type' | 'modified' | 'size';
66
+ export type SortDir = 'asc' | 'desc';
67
+
68
+ /**
69
+ * WHERE A LISTING'S ORDER CAME FROM — the third state the old `key: null`
70
+ * used to express, back as a mode rather than as a fourth key.
71
+ *
72
+ * `sort` — a folder's contents. The key and the direction below decide
73
+ * the order, which is what every control in the UI drives.
74
+ * `relevance` — a RANKED answer. The server decided the order and it is the
75
+ * whole value of the reply; re-applying a key here buries the
76
+ * row the person came for.
77
+ *
78
+ * ⚠⚠ Why a mode and not a key. A key is a preference: it is persisted, it is
79
+ * named by a button, and the user picks it. "Relevance" is none of those — it
80
+ * is a FACT about the rows in hand, it must not outlive them, and there is no
81
+ * meaningful "relevance descending". Making it a fifth member of `SortKey`
82
+ * would have persisted it into the next folder (where nothing is ranked) and
83
+ * put a word in the Sort-by menu that does nothing in nine listings out of
84
+ * ten.
85
+ *
86
+ * ⚠⚠ …and why it is an ARGUMENT and not a field on the store. The store is a
87
+ * pane's preference; this is a property of the ROWS a pane happens to be
88
+ * holding, and one pane can be showing a search while the other shows a
89
+ * folder. Same pane, two navigations, two answers — the caller holding the
90
+ * rows is the only one that knows which it has.
91
+ *
92
+ * Measured, 2026-09-13 (qldemo, query `s`, Name ↑): the server ranked
93
+ * `Documents/server.ts` first and the list drew it fifteenth of seventeen,
94
+ * behind every alphabetically earlier row. The owner's ruling that day:
95
+ * ordinary search and ⌘K stay in relevance order.
96
+ */
97
+ export type ListingOrder = 'sort' | 'relevance';
98
+
99
+ /** The four, in the order the menu draws them. One array, so the menu and
100
+ * `isSortKey` cannot disagree about how many there are. */
101
+ export const SORT_KEYS: readonly SortKey[] = ['name', 'type', 'modified', 'size'] as const;
102
+
103
+ const SORT_LS_KEY = 'filex.list-sort';
104
+
105
+ /**
106
+ * The order the listing arrives in when nobody has chosen one.
107
+ *
108
+ * ⚠ `name` / ascending, and NOT the reference build's `modified` / descending,
109
+ * deliberately: the backend answers a listing name-ascending already, so this
110
+ * default moves nothing on screen for an existing user — and our list view
111
+ * draws date group headings whenever the key is `modified`, which the
112
+ * reference has no equivalent of. Making `modified` the default would put a
113
+ * heading on the first paint of every folder that nobody asked for. Changing
114
+ * it is this one constant; the consequence is that heading.
115
+ */
116
+ const DEFAULT_KEY: SortKey = 'name';
117
+
118
+ /** Dates read newest-first; everything else reads A→Z / smallest-first. */
119
+ export function defaultSortDir(key: SortKey): SortDir {
120
+ return key === 'modified' ? 'desc' : 'asc';
121
+ }
122
+
123
+ function isSortKey(v: unknown): v is SortKey {
124
+ return typeof v === 'string' && (SORT_KEYS as readonly string[]).includes(v);
125
+ }
126
+
127
+ function readStored(): { key: SortKey; dir: SortDir } {
128
+ const fallback = { key: DEFAULT_KEY, dir: defaultSortDir(DEFAULT_KEY) };
129
+ if (typeof localStorage === 'undefined') return fallback;
130
+ try {
131
+ const raw = localStorage.getItem(SORT_LS_KEY);
132
+ if (!raw) return fallback;
133
+ const p = JSON.parse(raw) as { key?: unknown; dir?: unknown };
134
+ const key = isSortKey(p.key) ? p.key : DEFAULT_KEY;
135
+ const dir = p.dir === 'asc' || p.dir === 'desc' ? p.dir : defaultSortDir(key);
136
+ return { key, dir };
137
+ } catch {
138
+ return fallback; // private mode / bad JSON
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The active locale, for the one comparison that needs words: `type` sorts by
144
+ * the label the Type column PRINTS, so "Image" and "Görsel" have to fall in
145
+ * their own alphabet's order.
146
+ *
147
+ * ⚠ Module-level and NOT per store, and that is deliberate even now that the
148
+ * key and the direction are per pane: two panes of one window are read by one
149
+ * person in one language. If each store carried its own resolver, the pane
150
+ * whose host forgot to set it would sort by a different alphabet than the pane
151
+ * beside it — the drift this file exists to prevent, in the one field where it
152
+ * would be hardest to see.
153
+ */
154
+ const localeRef = ref<LocaleCode>('en');
155
+
156
+ export function setSortLocale(code: LocaleCode): void {
157
+ localeRef.value = code;
158
+ }
159
+
160
+ // ── the comparator ────────────────────────────────────────────────────
161
+
162
+ function modifiedMs(n: FileNode): number | null {
163
+ const v = n.last_modified;
164
+ if (!v) return null;
165
+ return v * (v < 1e12 ? 1000 : 1);
166
+ }
167
+
168
+ function nameCompare(a: FileNode, b: FileNode): number {
169
+ return (a.basename || '').localeCompare(b.basename || '', undefined, {
170
+ numeric: true,
171
+ sensitivity: 'base',
172
+ });
173
+ }
174
+
175
+ /** The word the Type column prints for this row — the thing a user sorting by
176
+ * "Type" is looking at. Unmapped kinds fall back to the uppercased extension,
177
+ * exactly as `typeLabelFor` does, so no row sorts as an empty string. */
178
+ function typeLabel(n: FileNode): string {
179
+ const key = typeLabelKey(n);
180
+ if (key) {
181
+ const cat = messages[localeRef.value] ?? messages.en;
182
+ return cat[key] ?? key;
183
+ }
184
+ const ext = (n.extension || '').trim();
185
+ return ext ? ext.toUpperCase() : '';
186
+ }
187
+
188
+ /** The active key's own order, direction applied. Folders-first is NOT here —
189
+ * it is the primary comparator in `compareNodes` and must not be reversed.
190
+ *
191
+ * ⚠ Written ONCE and closed over each store's refs, rather than copied into
192
+ * a store factory: the rule is the same in every pane and a per-pane copy of
193
+ * it is how the grid and the list drifted in the first place. */
194
+ function byActiveKey(key: SortKey, dirWord: SortDir, a: FileNode, b: FileNode): number {
195
+ const dir = dirWord === 'asc' ? 1 : -1;
196
+ switch (key) {
197
+ case 'name':
198
+ return dir * nameCompare(a, b);
199
+ case 'size':
200
+ return dir * ((a.size ?? -1) - (b.size ?? -1));
201
+ case 'type': {
202
+ const c = typeLabel(a).localeCompare(typeLabel(b), undefined, { sensitivity: 'base' });
203
+ // ⚠ The tiebreak is name-ASCENDING in both directions, on purpose: every
204
+ // image in a folder shares one label, so without it a Type sort leaves
205
+ // whole runs in whatever order the backend happened to answer, and
206
+ // "Type ↓" would scramble them again rather than reverse the groups.
207
+ return c !== 0 ? dir * c : nameCompare(a, b);
208
+ }
209
+ default: {
210
+ const am = modifiedMs(a);
211
+ const bm = modifiedMs(b);
212
+ // Undated rows go last in BOTH directions, so date groups stay clean.
213
+ if (am == null && bm == null) return 0;
214
+ if (am == null) return 1;
215
+ if (bm == null) return -1;
216
+ return dir * (am - bm);
217
+ }
218
+ }
219
+ }
220
+
221
+ // ── the store ─────────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * One pane's sort. Everything a surface needs to READ the order, to CHANGE it
225
+ * from either of the two control vocabularies, and to sort rows by it.
226
+ *
227
+ * ⚠ `key` and `dir` are exposed as refs so a `computed` that reads them
228
+ * subscribes to the next change — the property the module-level singleton had
229
+ * and the reason every listing re-sorted itself for free.
230
+ */
231
+ export interface SortStore {
232
+ readonly key: Ref<SortKey>;
233
+ readonly dir: Ref<SortDir>;
234
+ /** Set both halves outright. `dir` omitted → the key's own default. */
235
+ setSort(key: SortKey, dir?: SortDir): void;
236
+ /** Restore a remembered sort WITHOUT recording it as a fresh choice. */
237
+ applySort(key: SortKey, dir: SortDir): void;
238
+ /** Same order, other way round. The direction button's whole job. */
239
+ toggleSortDir(): void;
240
+ /** A column header click — see `chooseSortKey` below. */
241
+ chooseSortKey(key: SortKey): void;
242
+ /** Folders first, then the active key. Subscribes the reader. */
243
+ compareNodes(a: FileNode, b: FileNode): number;
244
+ /** THE comparator for a listing, given where its order came from. */
245
+ compareInOrder(order: ListingOrder): (a: FileNode, b: FileNode) => number;
246
+ /** The listing, in its order. A COPY. */
247
+ sortListing(files: FileNode[], order?: ListingOrder): FileNode[];
248
+ }
249
+
250
+ function persist(key: SortKey, dir: SortDir): void {
251
+ try {
252
+ localStorage.setItem(SORT_LS_KEY, JSON.stringify({ key, dir }));
253
+ } catch {
254
+ /* quota / private mode — the in-memory refs still moved */
255
+ }
256
+ }
257
+
258
+ /**
259
+ * A fresh sort store, seeded from the global default.
260
+ *
261
+ * ⚠⚠ Every store WRITES the global default when a person chooses in it, and
262
+ * that is the point rather than an oversight: `filex.list-sort` is defined as
263
+ * "the last sort a person chose", and a choice made in the right-hand pane is
264
+ * still a choice. What must not happen — and does not, because the refs are
265
+ * per store — is the OTHER pane moving when it does. Measured 2026-09-13:
266
+ * sorting the split pane by Size left the main pane on Name ↑ and made Size
267
+ * the arrangement the next unconfigured folder opens in.
268
+ */
269
+ export function createSortStore(): SortStore {
270
+ const stored = readStored();
271
+ const key = ref<SortKey>(stored.key);
272
+ const dir = ref<SortDir>(stored.dir);
273
+
274
+ function setSort(k: SortKey, d?: SortDir): void {
275
+ key.value = k;
276
+ dir.value = d ?? defaultSortDir(k);
277
+ persist(key.value, dir.value);
278
+ }
279
+
280
+ /**
281
+ * tablo:t1 — RESTORE a sort that was already the person's, without recording
282
+ * it as a fresh choice.
283
+ *
284
+ * ⚠⚠ The difference from `setSort` is the whole point and it is not
285
+ * cosmetic. `filex.list-sort` is the GLOBAL default — "what a folder I have
286
+ * never set up opens as" — and per-folder memory (`lib/viewPrefs`) is
287
+ * layered on top of it. If applying a folder's remembered sort went through
288
+ * `setSort`, walking into one folder that is sorted by size would quietly
289
+ * make Size the default for every folder in the product, and the person
290
+ * would have no idea which folder did it. Restoring is not choosing; only a
291
+ * choice writes the default.
292
+ */
293
+ function applySort(k: SortKey, d: SortDir): void {
294
+ key.value = k;
295
+ dir.value = d;
296
+ }
297
+
298
+ function toggleSortDir(): void {
299
+ dir.value = dir.value === 'asc' ? 'desc' : 'asc';
300
+ persist(key.value, dir.value);
301
+ }
302
+
303
+ /**
304
+ * A COLUMN HEADER click: a new key arrives in its own default direction, the
305
+ * key already active flips.
306
+ *
307
+ * ⚠ Not what the Sort-by menu calls — see `setSort`. A header is a toggle
308
+ * (click Name twice and you expect Z→A); a menu item is a destination (pick
309
+ * Name twice and you expect Name, still). Both meanings are written here so
310
+ * neither surface has to invent one.
311
+ */
312
+ function chooseSortKey(k: SortKey): void {
313
+ if (key.value === k) toggleSortDir();
314
+ else setSort(k);
315
+ }
316
+
317
+ function compareNodesFn(a: FileNode, b: FileNode): number {
318
+ return byFoldersFirst(a, b) || byActiveKey(key.value, dir.value, a, b);
319
+ }
320
+
321
+ function compareInOrderFn(order: ListingOrder): (a: FileNode, b: FileNode) => number {
322
+ return order === 'relevance' ? byFoldersFirst : compareNodesFn;
323
+ }
324
+
325
+ return {
326
+ key,
327
+ dir,
328
+ setSort,
329
+ applySort,
330
+ toggleSortDir,
331
+ chooseSortKey,
332
+ compareNodes: compareNodesFn,
333
+ compareInOrder: compareInOrderFn,
334
+ sortListing: (files: FileNode[], order: ListingOrder = 'sort') =>
335
+ [...(files || [])].sort(compareInOrderFn(order)),
336
+ };
337
+ }
338
+
339
+ /**
340
+ * The store a surface gets when nobody provided one: a bare `<ListView>` in a
341
+ * test, an embedder mounting one listing, and the explorer's own MAIN pane —
342
+ * which keeps using it so that everything already reading the module-level
343
+ * functions below (per-folder view memory, `displayFiles`) is talking about
344
+ * the same state the main pane's controls drive.
345
+ */
346
+ const defaultStore = createSortStore();
347
+
348
+ export const SORT_STORE_KEY: InjectionKey<SortStore> = Symbol('fe-sort-store');
349
+
350
+ /** Hand this subtree its own sort. Called by the pane component, once. */
351
+ export function provideSortStore(store: SortStore): void {
352
+ provide(SORT_STORE_KEY, store);
353
+ }
354
+
355
+ /**
356
+ * THE store for the surface calling it.
357
+ *
358
+ * ⚠ Must be called during `setup()` (it injects). Surfaces hold the result and
359
+ * read `.key` / `.dir` inside their computeds, which is what subscribes them.
360
+ */
361
+ export function useSortStore(): SortStore {
362
+ return inject(SORT_STORE_KEY, defaultStore);
363
+ }
364
+
365
+ // ── the default store's API, as free functions ────────────────────────
366
+ //
367
+ // Everything below is the module-level API this file shipped with, now a thin
368
+ // pass-through to the default store. Kept — not deprecated — because the sort
369
+ // is genuinely one answer for a host that draws ONE listing, which is every
370
+ // embed except the split view, and because the explorer's per-folder view
371
+ // memory reads and writes exactly this state for its main pane.
372
+
373
+ export function activeSortKey(): SortKey {
374
+ return defaultStore.key.value;
375
+ }
376
+
377
+ export function activeSortDir(): SortDir {
378
+ return defaultStore.dir.value;
379
+ }
380
+
381
+ export function setSort(key: SortKey, dir?: SortDir): void {
382
+ defaultStore.setSort(key, dir);
383
+ }
384
+
385
+ export function applySort(key: SortKey, dir: SortDir): void {
386
+ defaultStore.applySort(key, dir);
387
+ }
388
+
389
+ /**
390
+ * tablo:t1 — THE GLOBAL DEFAULT: the sort a folder nobody has configured opens
391
+ * in. Re-read from storage rather than mirrored in a second ref, because
392
+ * `setSort` writes there and `applySort` deliberately does not — so the stored
393
+ * value IS "the last sort a person chose", which is exactly the definition of
394
+ * the default, and a mirror could only drift from it.
395
+ */
396
+ export function globalSort(): { key: SortKey; dir: SortDir } {
397
+ return readStored();
398
+ }
399
+
400
+ export function toggleSortDir(): void {
401
+ defaultStore.toggleSortDir();
402
+ }
403
+
404
+ export function chooseSortKey(key: SortKey): void {
405
+ defaultStore.chooseSortKey(key);
406
+ }
407
+
408
+ /**
409
+ * The whole rule, in order: folders before files, then the active key.
410
+ *
411
+ * Reading this subscribes the caller to the sort state, so a `computed` built
412
+ * on it re-runs when the key or the direction changes.
413
+ */
414
+ export function compareNodes(a: FileNode, b: FileNode): number {
415
+ return defaultStore.compareNodes(a, b);
416
+ }
417
+
418
+ /**
419
+ * THE comparator for a listing, given where its order came from. Every
420
+ * surface that draws rows asks this one question; none of them re-derives the
421
+ * answer, which is the whole reason this module exists.
422
+ *
423
+ * ⚠⚠ `relevance` is `byFoldersFirst` and NOTHING ELSE — deliberately the
424
+ * exact function `GridView` already sorts by, so the grid needs no knowledge
425
+ * of relevance at all and cannot drift from the list. `web/tests/lib/
426
+ * searchRelevanceOrder.test.ts` asserts that identity; if this ever returns
427
+ * something else, that test goes red and names GridView as the file that has
428
+ * to change in the same commit. Teaching one view about relevance and leaving
429
+ * the other sorting is filex lesson #67 one level up.
430
+ *
431
+ * ⚠ Folders still come FIRST in a ranked set, and that is a decision, not an
432
+ * oversight. Folders-first is not a sort here, it is a GROUPING: the grid
433
+ * draws it as two labelled sections ("Folders" / "Files"), so a result set
434
+ * that interleaved them would make the grid's own headings false. The ranking
435
+ * is kept in full INSIDE each group — `Array#sort` is stable and this
436
+ * comparator returns 0 for any two rows of the same kind — which is as much
437
+ * relevance as a surface that groups folders can honestly offer. The cost is
438
+ * visible and bounded: a folder that ranked seventh is drawn above the file
439
+ * that ranked first. The alternative costs the grid its sections.
440
+ */
441
+ export function compareInOrder(order: ListingOrder): (a: FileNode, b: FileNode) => number {
442
+ return defaultStore.compareInOrder(order);
443
+ }
444
+
445
+ /**
446
+ * The listing, in its order. A COPY — the caller's array is usually a prop or
447
+ * a computed's source and sorting it in place mutates somebody else's state.
448
+ *
449
+ * `Array#sort` is stable, so rows the comparator calls equal keep the order
450
+ * they arrived in — which is precisely what carries the server's ranking
451
+ * through `relevance` mode.
452
+ *
453
+ * ⚠ `order` defaults to `'sort'`: an omitted argument is how every caller
454
+ * holding an ordinary folder listing asks for the active key, and that is the
455
+ * overwhelming majority of them.
456
+ */
457
+ export function sortListing(files: FileNode[], order: ListingOrder = 'sort'): FileNode[] {
458
+ return defaultStore.sortListing(files, order);
459
+ }