@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
@@ -0,0 +1,1036 @@
1
+ /**
2
+ * viewPrefs — HOW A FOLDER LOOKS, remembered.
3
+ *
4
+ * Two pieces of state live here, and the split between them is the whole
5
+ * design decision, so it is written down before any code:
6
+ *
7
+ * 1. PER-FOLDER, because it is a fact about the FOLDER. The view mode
8
+ * (list / grid / gallery) and the sort (key + direction). A photo album
9
+ * wants a gallery sorted by name; Downloads wants a list sorted by date.
10
+ * Those are properties of the contents, so they are remembered against
11
+ * the folder — Windows Explorer's behaviour, and what the owner asked
12
+ * for: "x folder'ında son görünüm nasıl kaldı ise öyle görünümde
13
+ * göstermemiz lazım."
14
+ *
15
+ * 2. GLOBAL, because it is a fact about YOUR SCREEN. Column widths and
16
+ * which columns you hide. The owner's own complaint is the proof: the
17
+ * Name column is crushed *at 960px*, which is the width of his review
18
+ * window — not a property of any folder he was standing in. Remembering
19
+ * a width per folder would mean dragging Name wider in Documents and
20
+ * finding it narrow again in Photos, forever. "I never look at Owner" is
21
+ * likewise a statement about the person, not about a folder.
22
+ *
23
+ * ⚠⚠ THE RULE, in one sentence, because a rule that cannot be said in one
24
+ * sentence cannot be explained to the person it surprises:
25
+ *
26
+ * Your last choice becomes the default for every folder you have never
27
+ * set up, and each folder you DO set up keeps its own — "Apply to all
28
+ * folders" throws the per-folder ones away.
29
+ *
30
+ * That is deliberately not "per-folder silently wins". Every change writes
31
+ * BOTH the folder's memory and the global default (`filex.view-mode`,
32
+ * `filex.list-sort`, which already existed and are not replaced here), so the
33
+ * person who switches to List while standing in one folder gets List in every
34
+ * folder they have not deliberately configured. The only surprise left is the
35
+ * folder they configured themselves last week, and that surprise IS the
36
+ * feature. The escape hatch exists for the day it stops being one.
37
+ *
38
+ * ⚠⚠ WHERE IT IS STORED: the DATABASE, against the user, as one JSON
39
+ * document — not `localStorage`, where every other view preference in this
40
+ * product lives. The owner's decision, 2026-09-13: "tarayıcıya değil db'ye
41
+ * kaydedeceğiz, basit bir json olarak. Oradan çekersek ayarları, tarayıcıda 36
42
+ * user ile girsin yine fark etmez."
43
+ *
44
+ * Browser storage is per BROWSER, not per person. On a shared machine — or any
45
+ * browser two accounts sign into one after the other — the second person
46
+ * silently inherits the first person's folder arrangements: no error, nothing
47
+ * to notice, just somebody else's layout. Namespacing the key by account would
48
+ * only narrow that; it could not close it, because the account is not known
49
+ * until a round trip has already happened. On the user row it cannot happen at
50
+ * all, and the arrangements follow the person to the desktop app and to their
51
+ * other machines — which is what "her user kendi görünümünü görür" actually
52
+ * asks for.
53
+ *
54
+ * ⚠ This file therefore holds NO transport. The explorer injects one
55
+ * (`attachViewPrefsStore`) the same way `useThumbs` is handed an api, so the
56
+ * module stays testable without a server and an embed with no account degrades
57
+ * to "remember nothing" rather than to an error.
58
+ *
59
+ * ⚠ Grouping is NOT a third axis. `ListView` draws date headings exactly when
60
+ * the sort key is `modified`, so "a time-grouped view in this folder" — the
61
+ * owner's own example — is already what remembering the sort key delivers.
62
+ * Adding a separate `grouping` field would create a state where the key says
63
+ * one thing and the grouping another, and no view could honestly draw both.
64
+ */
65
+ import { ref } from 'vue';
66
+
67
+ import type { SortDir, SortKey } from './sortOrder';
68
+
69
+ export type ViewMode = 'list' | 'grid' | 'gallery';
70
+
71
+ /**
72
+ * The columns, in the order the list draws them.
73
+ *
74
+ * ⚠⚠ `name` IS one of them now. It used to be excluded here with the note "it
75
+ * is the flexible column and is never hidden or sized by hand", and that
76
+ * exclusion is precisely the behaviour the owner rejected: as the only
77
+ * `1fr` track Name absorbed whatever every other column gave up, so narrowing
78
+ * Size made Name grow — "küçültme yapınca name kısmı büyüyormuş gibi davranıyor
79
+ * — bu davranış yanlış, istersem name'i de kısabilir olmalıyım." It has a real
80
+ * width like every other column and a handle of its own.
81
+ *
82
+ * It is still not HIDEABLE and not MOVABLE: it carries the tick, the tile and
83
+ * the row's click target, and every file manager pins it first.
84
+ */
85
+ export type ColumnId = 'name' | 'type' | 'location' | 'owner' | 'modified' | 'size' | 'star';
86
+
87
+ // ── per-folder memory ─────────────────────────────────────────────────
88
+
89
+ /**
90
+ * One folder's remembered setup. Short keys on purpose — this is a map with
91
+ * one entry per folder the person has configured, and the whole map is
92
+ * re-serialised on every write.
93
+ */
94
+ export interface FolderPrefs {
95
+ /** view mode */
96
+ v?: ViewMode;
97
+ /** sort key */
98
+ k?: SortKey;
99
+ /** sort direction */
100
+ d?: SortDir;
101
+ /** last used, epoch SECONDS — the LRU clock. */
102
+ t: number;
103
+ }
104
+
105
+ /**
106
+ * THE TRANSPORT — injected, so this module knows nothing about fetch, auth or
107
+ * URLs and a test can drive it with two functions.
108
+ *
109
+ * `load` resolves the stored document, or null when there is nobody to store
110
+ * one for (an embed on a shared app token, a public share link). `save` is
111
+ * FIRE AND FORGET on purpose: a view preference is never worth blocking an
112
+ * interface for, and a failed write must leave the change on screen — the next
113
+ * write sends the whole document anyway, so a dropped one heals itself.
114
+ */
115
+ export interface ViewPrefsTransport {
116
+ load: () => Promise<unknown>;
117
+ save: (doc: unknown) => void;
118
+ }
119
+
120
+ let transport: ViewPrefsTransport | null = null;
121
+
122
+ /**
123
+ * Has the document resolved yet?
124
+ *
125
+ * ⚠⚠ Nothing is applied to a folder until it has. This is the whole answer to
126
+ * "the remembered view arrives after the listing paints and the person sees
127
+ * list view jump to grid": the fetch is started at mount, in parallel with the
128
+ * first listing, and it is a single small row against a listing that has to
129
+ * walk a storage — so in practice it lands first. Correctness does not depend
130
+ * on that race, though, because a folder whose document has not arrived simply
131
+ * follows the global default and is corrected once, exactly when the answer is
132
+ * known.
133
+ *
134
+ * ⚠ It is also how a caller with NO user degrades cleanly: `load` resolves
135
+ * null, `ready` still becomes true, and every read below answers "nothing
136
+ * remembered" forever after. No error, no retry loop, no half state.
137
+ */
138
+ const ready = ref(false);
139
+ /** True when there is somebody to save for. False = read-only, session-only. */
140
+ const persistable = ref(false);
141
+
142
+ export function viewPrefsReady(): boolean {
143
+ return ready.value;
144
+ }
145
+
146
+ /**
147
+ * IS THE MEMORY ON AT ALL — a preference the person turns on, default OFF.
148
+ *
149
+ * Owner's ruling: "User bunu ayarlardan açabilir olacak isterse." Off, every
150
+ * folder follows the global view mode and sort exactly as it did before this
151
+ * module existed, and nobody is surprised by a folder that disagrees with the
152
+ * button they just pressed. On, a folder comes back the way they left it.
153
+ *
154
+ * ⚠ It lives in the same document as the folders themselves, so the switch
155
+ * follows the person between devices like everything else here. The control
156
+ * itself belongs to the host's user-settings modal, which is not this package
157
+ * — the contract is `folderMemoryEnabled()` to read and
158
+ * `setFolderMemoryEnabled()` to write.
159
+ */
160
+ const memoryOn = ref(false);
161
+
162
+ export function folderMemoryEnabled(): boolean {
163
+ return memoryOn.value;
164
+ }
165
+
166
+ export function setFolderMemoryEnabled(on: boolean): void {
167
+ if (memoryOn.value === on) return;
168
+ memoryOn.value = on;
169
+ scheduleSave();
170
+ }
171
+
172
+ type FolderMap = Record<string, FolderPrefs>;
173
+
174
+ function nowSec(): number {
175
+ return Math.floor(Date.now() / 1000);
176
+ }
177
+
178
+ function isViewMode(v: unknown): v is ViewMode {
179
+ return v === 'list' || v === 'grid' || v === 'gallery';
180
+ }
181
+
182
+ /** The map, in memory. A ref so a surface that lists the remembered folders
183
+ * (the column menu's "Apply to all folders" / "Forget this folder") re-renders
184
+ * when one is written or dropped. */
185
+ const folderMap = ref<FolderMap>({});
186
+
187
+ function readFolderMap(raw: unknown): FolderMap {
188
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
189
+ const out: FolderMap = {};
190
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
191
+ if (!v || typeof v !== 'object') continue;
192
+ const e = v as Record<string, unknown>;
193
+ const t = typeof e.t === 'number' && Number.isFinite(e.t) ? e.t : 0;
194
+ const entry: FolderPrefs = { t };
195
+ if (isViewMode(e.v)) entry.v = e.v;
196
+ if (e.k === 'name' || e.k === 'type' || e.k === 'modified' || e.k === 'size') entry.k = e.k;
197
+ if (e.d === 'asc' || e.d === 'desc') entry.d = e.d;
198
+ // An entry that remembers nothing is noise from a half-written document.
199
+ if (entry.v || entry.k) out[k] = entry;
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * HOW MANY FOLDERS WE REMEMBER, and how the number was chosen.
206
+ *
207
+ * ⚠⚠ Without a cap this is one entry per folder ever visited, forever — and
208
+ * now that the document lives in a database row rather than in a browser, it is
209
+ * a row that grows without bound for the life of the account, replicated into
210
+ * every backup. The endpoint refuses a document over 128 KB, so left uncapped
211
+ * the failure would eventually be that saving stops working, silently, for the
212
+ * people who use the product most.
213
+ *
214
+ * 300 is derived, not picked: an entry serialises to ~110 bytes (a qualified
215
+ * folder path plus four short fields), so 300 of them is ~33 KB — comfortably
216
+ * inside that limit with room for longer paths than the estimate assumed. And
217
+ * it is far more folders than a person deliberately configures: the map only
218
+ * gains an entry when someone CHANGES a view (see `touchFolder` — merely
219
+ * walking through a folder writes nothing), so in real use the cap is never
220
+ * reached and nothing is ever evicted. It is a ceiling on the pathological
221
+ * case, not a working limit.
222
+ */
223
+ export const FOLDER_CAP = 300;
224
+
225
+ /**
226
+ * Drop the least-recently-USED entries until the map fits the cap.
227
+ *
228
+ * ⚠ Used, not written: `touchFolder` bumps `t` when a remembered folder is
229
+ * OPENED as well as when it is changed, so a folder you keep visiting does not
230
+ * age out merely because you have not re-sorted it lately. Evicting by write
231
+ * time would throw away exactly the folders whose memory is working.
232
+ */
233
+ function evict(): void {
234
+ const entries = Object.entries(folderMap.value);
235
+ if (entries.length <= FOLDER_CAP) return;
236
+ entries.sort((a, b) => b[1].t - a[1].t); // newest first
237
+ const kept: FolderMap = {};
238
+ for (const [k, v] of entries.slice(0, FOLDER_CAP)) kept[k] = v;
239
+ folderMap.value = kept;
240
+ }
241
+
242
+ /**
243
+ * THE KEY a folder is remembered under.
244
+ *
245
+ * ⚠⚠ Read filex issue #21 before changing this. A storage's NAME is editable —
246
+ * that is the point of a name — so it is not a stable address; every protocol
247
+ * accepts a storage's immutable `uid` as the first path segment for exactly
248
+ * that reason (`backend/internal/storageref`). This function therefore takes
249
+ * the storage REF the host gave us and does not care which of the two it is:
250
+ * the moment `config.storages[].uid` is populated, keys built here become
251
+ * rename-proof with no change to this file.
252
+ *
253
+ * ⚠ Until then the key carries a storage NAME, and the consequences are worth
254
+ * stating rather than hiding:
255
+ * · renaming a storage loses its folders' memory (they fall back to the
256
+ * global default — a mild, self-healing loss, and the LRU ages the orphans
257
+ * out);
258
+ * · deleting a storage and creating a new one with the SAME name hands the
259
+ * new one the old one's remembered views. Rare, bounded to view mode and
260
+ * sort, and the fix is the one line of plumbing named above.
261
+ * What it does NOT do is collide across storages that exist at the same time,
262
+ * because two live storages cannot share a name.
263
+ *
264
+ * The relative path is normalised so `a/b`, `/a/b/` and `a//b` are one folder.
265
+ */
266
+ export function folderKey(storageRef: string, relPath: string): string {
267
+ const ref = String(storageRef ?? '').trim();
268
+ const rel = String(relPath ?? '')
269
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
270
+ .replace(/\/{2,}/g, '/')
271
+ .replace(/^\/+|\/+$/g, '');
272
+ if (!ref) return rel;
273
+ /* ⚠ No trailing slash on a storage ROOT. `thumbfix/` and `thumbfix` would be
274
+ * two keys for one folder, and the second is the one every caller builds —
275
+ * measured: the seeded default for `.recent` never fired, because the
276
+ * explorer asked for `.recent/` and the table is keyed `.recent`. */
277
+ return rel ? `${ref}/${rel}` : ref;
278
+ }
279
+
280
+ /**
281
+ * What a folder with NO memory of its own should open as, when the answer is
282
+ * not simply the global default.
283
+ *
284
+ * Only Recent is in here, and it earns it: Recent's whole promise is "the
285
+ * things you were just in", so opening it alphabetically buries the file the
286
+ * person came back for. Sorting it by date is also what makes its date
287
+ * headings honest — see the note on grouping in `FileExplorer`.
288
+ *
289
+ * ⚠ A seed, not an override: the moment someone sorts Recent by name, that
290
+ * choice is stored against `.recent` like any other folder's and this table
291
+ * stops being consulted for it.
292
+ */
293
+ const SEEDED: Record<string, Omit<FolderPrefs, 't'>> = {
294
+ '.recent': { k: 'modified', d: 'desc' },
295
+ };
296
+
297
+ /**
298
+ * What this folder is remembered as, or the seed, or nothing.
299
+ *
300
+ * ⚠⚠ The SEED is answered even when the memory is switched off, and the
301
+ * stored map only when it is on. They are two different things wearing one
302
+ * shape: the seed is what Recent sorts by — a property of that view, which the
303
+ * owner asked for directly and which must not hinge on an unrelated preference
304
+ * — while the map is the per-folder memory the preference governs. Putting the
305
+ * gate here rather than at the caller is what keeps every caller from having to
306
+ * remember the distinction.
307
+ */
308
+ export function folderPrefs(key: string): Omit<FolderPrefs, 't'> | null {
309
+ if (!key) return null;
310
+ if (memoryOn.value) {
311
+ const hit = folderMap.value[key];
312
+ if (hit) {
313
+ const { t: _t, ...rest } = hit;
314
+ void _t;
315
+ return rest;
316
+ }
317
+ }
318
+ return SEEDED[key] ?? null;
319
+ }
320
+
321
+ /** True when this folder has a memory the PERSON made (not a seed) — what the
322
+ * "Reset this folder" affordance is gated on. */
323
+ export function folderIsRemembered(key: string): boolean {
324
+ return memoryOn.value && !!key && !!folderMap.value[key];
325
+ }
326
+
327
+ /** How many folders are remembered — the "Apply to all folders" copy says so,
328
+ * because an escape hatch that does not say what it throws away is a trap. */
329
+ export function rememberedCount(): number {
330
+ return memoryOn.value ? Object.keys(folderMap.value).length : 0;
331
+ }
332
+
333
+ /**
334
+ * Remember a change against this folder.
335
+ *
336
+ * ⚠ The ONLY thing that creates an entry. Navigation does not (see
337
+ * `touchFolder`), so the map stays the set of folders somebody deliberately
338
+ * set up rather than a log of everywhere they have ever been — which is both
339
+ * why the cap is never reached in practice and why "Apply to all folders" has
340
+ * a small, comprehensible number to report.
341
+ */
342
+ export function rememberFolder(key: string, patch: Omit<FolderPrefs, 't'>): void {
343
+ if (!key || !memoryOn.value) return;
344
+ const prev = folderMap.value[key];
345
+ folderMap.value = {
346
+ ...folderMap.value,
347
+ [key]: { ...(prev ?? {}), ...patch, t: nowSec() },
348
+ };
349
+ evict();
350
+ scheduleSave();
351
+ }
352
+
353
+ /**
354
+ * Mark a remembered folder as used, for the LRU clock — and ONLY if it is
355
+ * already remembered. Called on navigation.
356
+ *
357
+ * ⚠ The guard is the point. Bumping an absent key would create an entry for
358
+ * every folder anyone ever opens, which is the unbounded growth the cap exists
359
+ * to survive; keeping the guard means the cap is a safety net rather than a
360
+ * working limit.
361
+ */
362
+ export function touchFolder(key: string): void {
363
+ if (!key || !memoryOn.value) return;
364
+ const prev = folderMap.value[key];
365
+ if (!prev) return;
366
+ const t = nowSec();
367
+ if (prev.t === t) return; // same second — nothing to write
368
+ folderMap.value = { ...folderMap.value, [key]: { ...prev, t } };
369
+ scheduleSave();
370
+ }
371
+
372
+ /** Forget one folder: it goes back to following the global default. */
373
+ export function forgetFolder(key: string): void {
374
+ if (!key || !folderMap.value[key]) return;
375
+ const next = { ...folderMap.value };
376
+ delete next[key];
377
+ folderMap.value = next;
378
+ scheduleSave();
379
+ }
380
+
381
+ /**
382
+ * THE ESCAPE HATCH — "Apply to all folders".
383
+ *
384
+ * Forgets every per-folder memory. The caller has already written the current
385
+ * folder's setup to the global default (every change does), so the visible
386
+ * effect is that every folder now opens the way this one does, which is what
387
+ * the words promise.
388
+ */
389
+ export function forgetAllFolders(): void {
390
+ folderMap.value = {};
391
+ scheduleSave();
392
+ }
393
+
394
+ // ── columns: widths + visibility (global) ─────────────────────────────
395
+
396
+
397
+ export interface ColumnSpec {
398
+ id: ColumnId;
399
+ /** Default width in px. */
400
+ width: number;
401
+ min: number;
402
+ max: number;
403
+ /** Can the person hide it from the header menu? */
404
+ hideable: boolean;
405
+ /** Can the person drag its edge? */
406
+ resizable: boolean;
407
+ }
408
+
409
+ /**
410
+ * The row's tracks, in shipped order.
411
+ *
412
+ * ⚠ Defaults narrower than the ones they replace (Owner 120 → 104, Modified
413
+ * 170 → 160): those numbers were set at 1440 where there is slack for them,
414
+ * and 170px of Modified is 170px Name does not get at 960.
415
+ *
416
+ * ⚠⚠ There is no `priority` field any more, and its absence is the whole
417
+ * change. It ordered a SHEDDING pass: when the tracks stopped fitting the
418
+ * pane, the least informative column was dropped and the header menu marked it
419
+ * "no room". The owner rejected that outright — "no room demesin, kenara devam
420
+ * eden bir scroll getirsin… genişletildikçe no room olmasın" — so nothing is
421
+ * dropped for want of room ever again. A table wider than its pane scrolls
422
+ * sideways, which is what a table does.
423
+ */
424
+ export const COLUMNS: readonly ColumnSpec[] = [
425
+ /* Name is pinned first and cannot be hidden, but its width is the person's
426
+ like any other (see the note on ColumnId). */
427
+ { id: 'name', width: 260, min: 120, max: 900, hideable: false, resizable: true },
428
+ { id: 'type', width: 88, min: 64, max: 220, hideable: true, resizable: true },
429
+ { id: 'location', width: 160, min: 80, max: 420, hideable: true, resizable: true },
430
+ { id: 'owner', width: 104, min: 72, max: 260, hideable: true, resizable: true },
431
+ { id: 'modified', width: 160, min: 96, max: 300, hideable: true, resizable: true },
432
+ { id: 'size', width: 88, min: 64, max: 200, hideable: true, resizable: true },
433
+ /* The star is a control, not a value: there is nothing to size and hiding it
434
+ is the Starred view's own call (`starEnabled`), not a column preference.
435
+ It is in this list only so one pass owns every optional track and no
436
+ stylesheet has to agree with it about which ones exist. */
437
+ { id: 'star', width: 24, min: 24, max: 24, hideable: false, resizable: false },
438
+ ] as const;
439
+
440
+ /** The two fixed tracks, in px, so the table arithmetic can be exact. */
441
+ export const FIXED_TRACKS = { check: 28, menu: 28 } as const;
442
+
443
+ /**
444
+ * THE NAME COLUMN'S HARD FLOOR — how narrow a person may drag it.
445
+ *
446
+ * 120px, which at the list's 13px/500 is the 24px type tile, its 8px gap and
447
+ * about eleven characters. Short, and deliberately so: the owner asked to be
448
+ * able to shrink Name ("istersem name'i de kısabilir olmalıyım"), and a floor
449
+ * that refuses at 220 would be the old behaviour wearing a smaller number.
450
+ * Below 120 the cell is the tile and an ellipsis — a column that has stopped
451
+ * being a name column at all — so that is where it stops.
452
+ *
453
+ * ⚠ Not to be confused with `NAME_AUTO`: this is what a DRAG may reach, that
454
+ * is what an untouched table opens at.
455
+ */
456
+ export const NAME_MIN = 120;
457
+
458
+ /**
459
+ * What Name opens at when nobody has ever dragged anything.
460
+ *
461
+ * 220px holds roughly 26 characters at 13px/500 — enough for
462
+ * `Quarterly Report Q2.pdf` to read whole, which is the length of a real
463
+ * document name rather than a demo fixture's. The auto pass never goes below
464
+ * it: on a phone it would rather let the table run off the side (it is going
465
+ * to anyway, with six columns on a 390px screen) than open showing eleven
466
+ * characters of every filename.
467
+ */
468
+ export const NAME_AUTO = 220;
469
+
470
+ interface ColsState {
471
+ w: Partial<Record<ColumnId, number>>;
472
+ hidden: ColumnId[];
473
+ /** The person's column order. Absent = the shipped order in `COLUMNS`. */
474
+ o?: ColumnId[];
475
+ }
476
+
477
+ function specOf(id: ColumnId): ColumnSpec | undefined {
478
+ return COLUMNS.find((c) => c.id === id);
479
+ }
480
+
481
+ function isColumnId(v: unknown): v is ColumnId {
482
+ return typeof v === 'string' && COLUMNS.some((c) => c.id === v);
483
+ }
484
+
485
+ function readCols(raw: unknown): ColsState {
486
+ const fallback: ColsState = { w: {}, hidden: [] };
487
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return fallback;
488
+ {
489
+ const p = raw as { w?: unknown; hidden?: unknown; o?: unknown };
490
+ const w: Partial<Record<ColumnId, number>> = {};
491
+ if (p.w && typeof p.w === 'object') {
492
+ for (const [k, v] of Object.entries(p.w as Record<string, unknown>)) {
493
+ if (!isColumnId(k) || typeof v !== 'number' || !Number.isFinite(v)) continue;
494
+ const spec = specOf(k);
495
+ if (!spec) continue;
496
+ // ⚠ Clamped on the way IN as well as on the way out: a width stored by
497
+ // an older build (or hand-edited) outside today's bounds would
498
+ // otherwise draw a column nobody can reach the edge of, and nothing in
499
+ // the UI would explain it.
500
+ w[k] = Math.min(spec.max, Math.max(spec.min, Math.round(v)));
501
+ }
502
+ }
503
+ const hidden = Array.isArray(p.hidden)
504
+ ? (p.hidden.filter((x) => isColumnId(x) && specOf(x)?.hideable) as ColumnId[])
505
+ : [];
506
+ const o = Array.isArray(p.o)
507
+ ? ([...new Set(p.o.filter(isColumnId))] as ColumnId[])
508
+ : undefined;
509
+ return { w, hidden: [...new Set(hidden)], o };
510
+ }
511
+ }
512
+
513
+ const colsState = ref<ColsState>(readCols(null));
514
+
515
+ /**
516
+ * THE COLUMN ORDER — the person's, reconciled with the columns that exist.
517
+ *
518
+ * ⚠⚠ Reconciled, not trusted. A stored order is a list written by an older
519
+ * build, so it can be missing a column added since and can name one removed
520
+ * since. Returning it as-is would make a new column INVISIBLE to everyone who
521
+ * had ever touched the order — a feature that ships and then does not appear
522
+ * for exactly the users engaged enough to have customised something. So:
523
+ * stored entries that still exist keep their place, and anything the stored
524
+ * list never heard of is inserted at its own shipped index.
525
+ *
526
+ * ⚠ ORDER IS GLOBAL, like width and visibility, and unlike the view mode. Two
527
+ * reasons, and the second is the one that would have bitten:
528
+ * · it is a reading habit ("I want Size next to Name"), not a property of a
529
+ * folder's contents, which is the line this whole module is drawn on;
530
+ * · widths are global too, and a per-folder order with global widths would
531
+ * mean the same six columns at the same six sizes arriving in a different
532
+ * sequence in every folder — a layout that reads as random rather than as
533
+ * the person's.
534
+ */
535
+ export function columnOrder(): ColumnId[] {
536
+ const stored = colsState.value.o;
537
+ const known = COLUMNS.map((c) => c.id);
538
+ if (!stored || stored.length === 0) return known;
539
+ /* ⚠ Name first, whatever the document says. It is pinned in the table, so an
540
+ order that claimed otherwise would be a sequence nothing on screen obeys —
541
+ and a stored order written before Name was a column at all has it appended
542
+ at the END by the reconciliation below. */
543
+ const out = stored.filter((id) => known.includes(id) && id !== 'name');
544
+ out.unshift('name');
545
+ /* ⚠ A column the stored list never heard of goes to the END, not to its own
546
+ * shipped index. Measured: inserting at the shipped index walked newcomers
547
+ * in FRONT of the columns the person had deliberately moved — somebody who
548
+ * had put Size first would open a later release to find two new columns
549
+ * ahead of it and their arrangement silently rewritten. Appending leaves the
550
+ * stored sequence exactly as it was and puts the newcomer where it is
551
+ * visible without being in the way. */
552
+ for (const id of known) if (!out.includes(id)) out.push(id);
553
+ return out;
554
+ }
555
+
556
+ /** Put `id` at `index` in the order. Everything else closes up behind it. */
557
+ export function moveColumn(id: ColumnId, index: number): void {
558
+ const spec = specOf(id);
559
+ /* The star is not the person's to move: it is a control pinned to the
560
+ trailing edge beside the row menu, not a value with a place in a sequence. */
561
+ if (!spec || !spec.hideable) return;
562
+ const cur = columnOrder();
563
+ const from = cur.indexOf(id);
564
+ if (from === -1) return;
565
+ const next = [...cur];
566
+ next.splice(from, 1);
567
+ next.splice(Math.max(0, Math.min(next.length, index)), 0, id);
568
+ if (next.join() === cur.join()) return;
569
+ colsState.value = { ...colsState.value, o: next };
570
+ scheduleSave();
571
+ }
572
+
573
+ /** One step left or right — what the header menu offers, so the whole gesture
574
+ * is reachable without a pointer. */
575
+ export function moveColumnBy(id: ColumnId, delta: -1 | 1): void {
576
+ const cur = columnOrder().filter((c) => specOf(c)?.hideable);
577
+ const at = cur.indexOf(id);
578
+ if (at === -1) return;
579
+ const to = at + delta;
580
+ if (to < 0 || to >= cur.length) return;
581
+ /* Computed against the MOVABLE columns only, then translated back into the
582
+ full order: stepping "right" past the pinned star would look like nothing
583
+ happening, which reads as a broken button rather than as a boundary. */
584
+ const neighbour = cur[to];
585
+ const full = columnOrder();
586
+ moveColumn(id, full.indexOf(neighbour));
587
+ }
588
+
589
+ /** Can this column still move that way? The menu greys the ends rather than
590
+ * offering a button that does nothing. */
591
+ export function canMoveColumn(id: ColumnId, delta: -1 | 1): boolean {
592
+ const cur = columnOrder().filter((c) => specOf(c)?.hideable);
593
+ const at = cur.indexOf(id);
594
+ if (at === -1) return false;
595
+ return at + delta >= 0 && at + delta < cur.length;
596
+ }
597
+
598
+ /** This column's width right now — the person's, or the default. */
599
+ export function columnWidth(id: ColumnId): number {
600
+ const spec = specOf(id);
601
+ if (!spec) return 0;
602
+ return colsState.value.w[id] ?? spec.width;
603
+ }
604
+
605
+ /** Set a width, clamped to the column's own bounds. */
606
+ export function setColumnWidth(id: ColumnId, px: number): void {
607
+ const spec = specOf(id);
608
+ if (!spec || !spec.resizable) return;
609
+ const next = Math.min(spec.max, Math.max(spec.min, Math.round(px)));
610
+ if (colsState.value.w[id] === next) return;
611
+ colsState.value = { ...colsState.value, w: { ...colsState.value.w, [id]: next } };
612
+ scheduleSave();
613
+ }
614
+
615
+ /**
616
+ * HAS ANYBODY EVER SIZED THIS TABLE — the one bit that separates "I have never
617
+ * touched this" from "I deliberately made Name narrow".
618
+ *
619
+ * ⚠⚠ Without it the two are indistinguishable and the first resize is undone
620
+ * by the next pane resize: an untouched table is sized to the pane it is in
621
+ * (`tableLayout`), so a person who drags Name to 140 and then opens the
622
+ * inspector gets Name recomputed back to whatever the narrower pane suggests.
623
+ * The stored widths are that bit — the moment they exist, the pane stops
624
+ * having an opinion.
625
+ *
626
+ * ⚠⚠ The bit is NAME's width, not "the map is empty", and that is a migration
627
+ * decision rather than a stylistic one. Name was not a column until this
628
+ * change, so every document written before it — including the owner's, which
629
+ * carries `{size: 89, type: 125}` from the layout he rejected — has widths but
630
+ * has never been asked about Name. Reading those as "configured" would open
631
+ * his table at Name's shipped 260px, wider than his pane, scrolled sideways on
632
+ * first sight, at a width nobody ever chose. Reading them as "Name is still
633
+ * auto" keeps the two numbers he did choose and derives the one he did not.
634
+ * The first drag writes every width at once (`freezeWidths`), so a table
635
+ * somebody has actually sized can never fall back into this branch.
636
+ */
637
+ export function widthsAreAuto(): boolean {
638
+ return colsState.value.w.name === undefined;
639
+ }
640
+
641
+ /**
642
+ * Freeze the widths currently ON SCREEN, for the columns that have none stored.
643
+ *
644
+ * ⚠⚠ Called by the view at the START of a resize gesture, and it has to write
645
+ * EVERY drawn column, not the one being dragged. Writing only the dragged
646
+ * column would leave Name auto — and auto Name takes the slack, so narrowing
647
+ * Size would still widen Name, which is the exact behaviour the owner called
648
+ * wrong. Freezing the whole row first turns the gesture into what it looks
649
+ * like: one column changes, the others stay where they are.
650
+ *
651
+ * Existing entries are never overwritten: the widths handed in are what the
652
+ * person is already looking at, and the stored ones are already that.
653
+ */
654
+ export function freezeWidths(widths: Partial<Record<ColumnId, number>>): void {
655
+ const w: Partial<Record<ColumnId, number>> = { ...colsState.value.w };
656
+ let changed = false;
657
+ for (const [k, v] of Object.entries(widths)) {
658
+ if (!isColumnId(k) || typeof v !== 'number' || !Number.isFinite(v)) continue;
659
+ if (w[k] !== undefined) continue;
660
+ const spec = specOf(k);
661
+ if (!spec || !spec.resizable) continue;
662
+ w[k] = Math.min(spec.max, Math.max(spec.min, Math.round(v)));
663
+ changed = true;
664
+ }
665
+ if (!changed) return;
666
+ colsState.value = { ...colsState.value, w };
667
+ scheduleSave();
668
+ }
669
+
670
+ /** True when the PERSON has hidden this column — the only reason a column is
671
+ * ever absent now that width sheds nothing (see `tableLayout`). */
672
+ export function columnHidden(id: ColumnId): boolean {
673
+ return colsState.value.hidden.includes(id);
674
+ }
675
+
676
+ export function setColumnHidden(id: ColumnId, hidden: boolean): void {
677
+ const spec = specOf(id);
678
+ if (!spec || !spec.hideable) return;
679
+ if (columnHidden(id) === hidden) return;
680
+ const set = new Set(colsState.value.hidden);
681
+ if (hidden) set.add(id);
682
+ else set.delete(id);
683
+ colsState.value = { ...colsState.value, hidden: [...set] };
684
+ scheduleSave();
685
+ }
686
+
687
+ /** Back to the shipped widths, order and visibility — one button for all
688
+ * three, because "reset the columns" is one thought. */
689
+ export function resetColumns(): void {
690
+ colsState.value = { w: {}, hidden: [] };
691
+ scheduleSave();
692
+ }
693
+
694
+ /** True when anything about the columns has been changed by hand — what the
695
+ * header menu's "Reset columns" is gated on. */
696
+ export function columnsCustomised(): boolean {
697
+ return (
698
+ Object.keys(colsState.value.w).length > 0 ||
699
+ colsState.value.hidden.length > 0 ||
700
+ (colsState.value.o?.length ?? 0) > 0
701
+ );
702
+ }
703
+
704
+ /* ── the document ─────────────────────────────────────────
705
+ *
706
+ * One JSON object, holding everything this module owns:
707
+ *
708
+ * { "on": true, the opt-in switch
709
+ * "f": { "<storage>/<rel>": {v,k,d,t}, … }, per-folder, LRU-capped
710
+ * "c": { "w": {…}, "hidden": […], "o": […] } } columns, global
711
+ *
712
+ * Short keys because the whole thing is re-serialised on every save and lives
713
+ * in a database column: at the 300-folder cap that is roughly 33 KB, and the
714
+ * endpoint refuses anything over 128 KB.
715
+ */
716
+
717
+ interface ViewPrefsDoc {
718
+ on?: boolean;
719
+ f?: unknown;
720
+ c?: unknown;
721
+ [ns: string]: unknown;
722
+ }
723
+
724
+ /** The three top-level names this module owns. Everything else in the
725
+ * document belongs to somebody else and is none of our business. */
726
+ const OWNED_KEYS = ['on', 'f', 'c'] as const;
727
+
728
+ /**
729
+ * ⚠⚠ EVERYTHING IN THE DOCUMENT THAT IS NOT OURS, kept verbatim.
730
+ *
731
+ * The endpoint stores an OPAQUE object — `handlers/viewprefs.go` validates it
732
+ * as JSON, bounds its size and does not look inside, precisely so the shape
733
+ * can change without the server changing with it. This module was the half
734
+ * that broke that promise: `currentDoc()` rebuilt `{on, f, c}` from scratch on
735
+ * every save, so a field written by any other feature was gone the next time
736
+ * somebody dragged a column. Measured, and one resize was enough:
737
+ *
738
+ * loaded: {"on":true,"f":{},"c":{},"install":{"dismissed":1}}
739
+ * saved: {"on":true,"f":{},"c":{"w":{"size":130},"hidden":[]}}
740
+ *
741
+ * No error, nothing on screen, and the feature that lost its value could only
742
+ * discover it by looking. The desktop-app reminder's "never show me again" was
743
+ * left in `localStorage` because of this — per browser profile, so a second
744
+ * machine, a private window or cleared site data asked again.
745
+ *
746
+ * ⚠ A `ref`, like everything else here, because a slot's value IS rendered:
747
+ * the desktop-app reminder reads its own key inside a `computed` and has to
748
+ * settle by itself when the document lands a moment after the page does.
749
+ */
750
+ const foreign = ref<Record<string, unknown>>({});
751
+
752
+ /** Split a loaded document into "ours" and "everybody else's". */
753
+ function takeForeign(doc: Record<string, unknown>): Record<string, unknown> {
754
+ const rest: Record<string, unknown> = {};
755
+ for (const [k, v] of Object.entries(doc)) {
756
+ if ((OWNED_KEYS as readonly string[]).includes(k)) continue;
757
+ rest[k] = v;
758
+ }
759
+ return rest;
760
+ }
761
+
762
+ function currentDoc(): ViewPrefsDoc {
763
+ /* ⚠ Spread FIRST, so the three keys this module owns always win: a foreign
764
+ * key called `c` cannot overwrite the column state on its way back out. */
765
+ return { ...foreign.value, on: memoryOn.value, f: folderMap.value, c: colsState.value };
766
+ }
767
+
768
+ /* ── namespaced slots: room in the document for somebody else ────────────
769
+ *
770
+ * A feature outside this module keeps a value in the SAME per-user document
771
+ * without reaching into its shape — it gets one top-level key of its own and
772
+ * cannot see or touch anybody else's. That is the whole API surface, on
773
+ * purpose: a general "write anywhere in the document" helper is how the next
774
+ * feature ends up owning `c`.
775
+ *
776
+ * ⚠ It is the same document, so it inherits its two properties exactly: it
777
+ * saves only when there is somebody to save FOR (a share link and an app-token
778
+ * embed have no account — `load` answers null and this writes nothing, for
779
+ * ever), and the write is debounced and fire-and-forget. A caller that must
780
+ * work with no account needs a fallback of its own; `web/src/composables/
781
+ * useInstallPrompt.ts` is the worked example.
782
+ */
783
+
784
+ /** One feature's corner of the document. */
785
+ export interface ViewPrefsSlot<T> {
786
+ /** What is stored, or undefined — including before the document lands. */
787
+ get(): T | undefined;
788
+ /** Replace it. A no-op when there is nobody to save for. */
789
+ set(value: T): void;
790
+ /** Remove the key entirely. */
791
+ clear(): void;
792
+ /** Has the document been read yet? Reactive — read it in a `computed`. */
793
+ ready(): boolean;
794
+ /** Is there an account behind this session at all? Reactive. */
795
+ persistable(): boolean;
796
+ }
797
+
798
+ /**
799
+ * Claim `namespace` as a top-level key in the per-user view-prefs document.
800
+ *
801
+ * ⚠ The three names this module owns are refused outright rather than
802
+ * quietly renamed: a slot called `c` that silently became `c2` would be a
803
+ * value the caller could never read back.
804
+ */
805
+ export function viewPrefsSlot<T>(namespace: string): ViewPrefsSlot<T> {
806
+ if (!namespace || (OWNED_KEYS as readonly string[]).includes(namespace)) {
807
+ throw new Error(`viewPrefsSlot: "${namespace}" is reserved by lib/viewPrefs`);
808
+ }
809
+ return {
810
+ get: () => foreign.value[namespace] as T | undefined,
811
+ set(value: T) {
812
+ foreign.value = { ...foreign.value, [namespace]: value };
813
+ scheduleSave();
814
+ },
815
+ clear() {
816
+ if (!(namespace in foreign.value)) return;
817
+ const next = { ...foreign.value };
818
+ delete next[namespace];
819
+ foreign.value = next;
820
+ scheduleSave();
821
+ },
822
+ ready: () => ready.value,
823
+ persistable: () => persistable.value,
824
+ };
825
+ }
826
+
827
+ /**
828
+ * SAVING — debounced, coalesced, fire and forget.
829
+ *
830
+ * ⚠⚠ A view change is a keystroke-rate event once a column can be dragged: a
831
+ * single resize is dozens of `setColumnWidth` calls, one per pointermove. A PUT
832
+ * per change would hammer the server and, worse, race itself — two writes in
833
+ * flight of a document that is replaced wholesale, arriving in whatever order
834
+ * the network chose. Waiting for the gesture to settle and then sending the
835
+ * WHOLE current document makes the last write the true one by construction,
836
+ * and makes a dropped write self-healing: the next save carries everything the
837
+ * lost one did.
838
+ */
839
+ const SAVE_DEBOUNCE_MS = 800;
840
+ let saveTimer: ReturnType<typeof setTimeout> | undefined;
841
+ let saveDirty = false;
842
+
843
+ function scheduleSave(): void {
844
+ /* ⚠ Not before the document has loaded. A save fired while the fetch is
845
+ * still in flight would write this session's empty defaults over everything
846
+ * the person had arranged — the classic "my settings reset themselves".
847
+ * Nothing mutates the state before `ready` except the load itself. */
848
+ if (!ready.value || !persistable.value || !transport) return;
849
+ saveDirty = true;
850
+ if (saveTimer) clearTimeout(saveTimer);
851
+ saveTimer = setTimeout(flushSave, SAVE_DEBOUNCE_MS);
852
+ }
853
+
854
+ function flushSave(): void {
855
+ if (saveTimer) clearTimeout(saveTimer);
856
+ saveTimer = undefined;
857
+ if (!saveDirty || !transport || !persistable.value) return;
858
+ saveDirty = false;
859
+ transport.save(currentDoc());
860
+ }
861
+
862
+ /**
863
+ * ⚠ A change made in the last moment before a tab closes is inside the
864
+ * debounce window and would simply be lost. `pagehide` is the last event a
865
+ * browser reliably delivers (`beforeunload` never fires on mobile, and
866
+ * `unload` is skipped when a page enters the back/forward cache), so the
867
+ * pending document goes out there — the transport sends it with `keepalive`,
868
+ * which is what lets a request outlive the document that started it.
869
+ */
870
+ if (typeof window !== 'undefined') {
871
+ window.addEventListener('pagehide', flushSave);
872
+ window.addEventListener('visibilitychange', () => {
873
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') flushSave();
874
+ });
875
+ }
876
+
877
+ /**
878
+ * Hand this module its transport and read the document.
879
+ *
880
+ * Called once per explorer mount. Calling it again is a no-op on purpose: two
881
+ * explorers on one page share this module's state, and the second must not
882
+ * re-fetch over the first.
883
+ */
884
+ export function attachViewPrefsStore(t: ViewPrefsTransport): void {
885
+ if (transport) return;
886
+ transport = t;
887
+ void (async () => {
888
+ let doc: unknown = null;
889
+ try {
890
+ doc = await t.load();
891
+ } catch {
892
+ /* No account, offline, a 500 — all one answer: remember nothing this
893
+ session and never write, so a transient failure cannot erase what is
894
+ already stored. */
895
+ doc = null;
896
+ }
897
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) {
898
+ const d = doc as ViewPrefsDoc;
899
+ memoryOn.value = d.on === true;
900
+ folderMap.value = readFolderMap(d.f);
901
+ colsState.value = readCols(d.c);
902
+ /* ⚠ Whatever else was in there travels on untouched. This is the half
903
+ * that makes the endpoint's "opaque object" promise true on the client
904
+ * as well — see `foreign` above for what it cost while it was false. */
905
+ foreign.value = takeForeign(d as Record<string, unknown>);
906
+ persistable.value = true;
907
+ }
908
+ ready.value = true;
909
+ })();
910
+ }
911
+
912
+ /** Test seam: drop the transport and the state, so one suite cannot leak a
913
+ * document into the next. Not used by the app. */
914
+ export function __resetViewPrefs(): void {
915
+ transport = null;
916
+ ready.value = false;
917
+ persistable.value = false;
918
+ memoryOn.value = false;
919
+ folderMap.value = {};
920
+ colsState.value = readCols(null);
921
+ foreign.value = {};
922
+ saveDirty = false;
923
+ if (saveTimer) clearTimeout(saveTimer);
924
+ saveTimer = undefined;
925
+ }
926
+
927
+ /** Test seam: send whatever is pending now, without waiting out the debounce. */
928
+ export function __flushViewPrefs(): void {
929
+ flushSave();
930
+ }
931
+
932
+ /** What one table looks like right now: which optional columns are drawn, how
933
+ * wide every track is, and how wide the table therefore is. */
934
+ export interface TableLayout {
935
+ /** The optional columns, in the person's order. Never shortened for width. */
936
+ visible: ColumnId[];
937
+ /** Every drawn track's width in px, Name included. */
938
+ widths: Record<ColumnId, number>;
939
+ /** The table's own width: every track, every gap, both paddings. */
940
+ total: number;
941
+ /** True while these widths are derived from the pane rather than stored. */
942
+ auto: boolean;
943
+ }
944
+
945
+ /**
946
+ * THE TABLE'S WIDTHS — one answer, used by the header and by every row.
947
+ *
948
+ * ⚠⚠ This replaces `fitColumns`, and the difference is not a tuning. That pass
949
+ * fitted the columns TO the container and dropped one whenever they stopped
950
+ * fitting; the header menu then told the person there was "no room". The owner
951
+ * rejected the whole model, verbatim: "no room demesin, kenara devam eden bir
952
+ * scroll getirsin… tablo vertical ve horizontal scrollable olarak açılacak,
953
+ * genişletildikçe no room olmasın." So:
954
+ *
955
+ * · nothing is ever dropped for want of room — `visible` is the person's
956
+ * choice and the caller's candidates, and width has no vote;
957
+ * · the table's width is the SUM of its columns, so when that exceeds the
958
+ * pane the table scrolls sideways (the view's job) instead of shrinking;
959
+ * · Name is an ordinary column with a width of its own, so narrowing Size no
960
+ * longer widens Name.
961
+ *
962
+ * ⚠ `candidates` is what the CALLER is willing to draw at all: Location is
963
+ * only meaningful where rows come from more than one folder, and the star only
964
+ * where starring is offered. Passing them is how those two facts stay with the
965
+ * component that knows them instead of being re-derived here.
966
+ *
967
+ * ⚠⚠ AUTO vs STORED, which is the only subtle thing left. With nothing stored
968
+ * (`widthsAreAuto`), the widths are derived from the pane so a table nobody has
969
+ * configured opens sensibly at 1440 and at 390 alike: Name asks for `NAME_AUTO`,
970
+ * the other columns give up room toward their own minimums to pay for it, and
971
+ * Name takes any slack that is left. The instant one width is stored — and the
972
+ * view stores ALL of them on the first drag, see `freezeWidths` — the pane
973
+ * stops having an opinion and these are the person's numbers, at every window
974
+ * size, forever. Without that line a resize would be silently undone by the
975
+ * next auto-fit, which is the trap this pair exists to avoid.
976
+ */
977
+ export function tableLayout(
978
+ available: number,
979
+ candidates: readonly ColumnId[],
980
+ opts: { gap?: number; padding?: number } = {},
981
+ ): TableLayout {
982
+ const gap = opts.gap ?? 8; // --fe-gap-sm
983
+ const padding = opts.padding ?? 24; // --fe-gap on both sides
984
+
985
+ const visible = columnOrder().filter(
986
+ (id) => id !== 'name' && candidates.includes(id) && !columnHidden(id),
987
+ );
988
+
989
+ const widths = {} as Record<ColumnId, number>;
990
+ for (const id of visible) widths[id] = columnWidth(id);
991
+ widths.name = columnWidth('name');
992
+
993
+ /* tick + Name + the optional ones + ⋮, so one gap fewer than that. */
994
+ const chrome =
995
+ FIXED_TRACKS.check + FIXED_TRACKS.menu + padding + gap * (visible.length + 2);
996
+ const auto = widthsAreAuto();
997
+ const nameSpec = specOf('name')!;
998
+
999
+ /* A container we cannot measure yet (0 on the first tick, or a test with no
1000
+ layout) keeps the shipped widths — the observer corrects it one frame
1001
+ later, and a table that guessed would flash. */
1002
+ if (auto && available > 0) {
1003
+ const room = available - chrome;
1004
+ let rest = visible.reduce((s, id) => s + widths[id], 0);
1005
+ /* What the other columns may occupy if Name is to open at its comfortable
1006
+ width. Over that, they give up room in proportion to how much each HAS
1007
+ to give, so a 300px Modified yields more than a 24px star (which yields
1008
+ nothing — its min is its max). */
1009
+ const target = room - NAME_AUTO;
1010
+ if (rest > target) {
1011
+ const give = visible.reduce((s, id) => s + (widths[id] - specOf(id)!.min), 0);
1012
+ if (give > 0) {
1013
+ const f = Math.min(1, (rest - target) / give);
1014
+ for (const id of visible) {
1015
+ const spec = specOf(id)!;
1016
+ /* ⚠ `ceil` on the reduction, not `floor`. Rounding the other way
1017
+ leaves the row a pixel or two over the pane and hands an otherwise
1018
+ perfectly fitting table a horizontal scrollbar on first sight —
1019
+ measured at 960, where the arithmetic lands on 712 in a 711px
1020
+ pane. Over-shrinking by a pixel is invisible; Name absorbs it. */
1021
+ widths[id] = Math.max(spec.min, widths[id] - Math.ceil((widths[id] - spec.min) * f));
1022
+ }
1023
+ rest = visible.reduce((s, id) => s + widths[id], 0);
1024
+ }
1025
+ }
1026
+ /* Slack goes to Name, and only to Name. Below `NAME_AUTO` it stops: at
1027
+ that point the table is wider than the pane and scrolls, which beats
1028
+ opening with eleven characters of every filename. */
1029
+ widths.name = Math.round(
1030
+ Math.max(NAME_AUTO, Math.min(nameSpec.max, room - rest)),
1031
+ );
1032
+ }
1033
+
1034
+ const total = chrome + widths.name + visible.reduce((s, id) => s + widths[id], 0);
1035
+ return { visible, widths, total, auto };
1036
+ }