@brftech/filex-core 0.42.1 → 0.43.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 (233) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5241 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1207 -343
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +47 -17
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -56
  76. package/src/components/SSHKeysPanel.vue +79 -26
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -27
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +121 -0
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +522 -119
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +539 -155
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2895 -241
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +58 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-CLNevfPE.js +0 -124
  216. package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
  217. package/dist/CsvViewer-K8XA3z7N.js +0 -141
  218. package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
  219. package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
  220. package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
  221. package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
  222. package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
  223. package/dist/MermaidViewer-DqucH_io.js +0 -137
  224. package/dist/MermaidViewer-DqucH_io.js.map +0 -1
  225. package/dist/PsdViewer-CwiVENmq.js +0 -122
  226. package/dist/PsdViewer-CwiVENmq.js.map +0 -1
  227. package/dist/TiffViewer-CpewSVmQ.js +0 -142
  228. package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
  229. package/dist/Viewer3D-CIrOn63I.js.map +0 -1
  230. package/dist/index-Dx0IbBcI.js +0 -26831
  231. package/dist/index-Dx0IbBcI.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
@@ -1,96 +1,95 @@
1
1
  /**
2
- * viewPrefs — HOW A FOLDER LOOKS, remembered.
2
+ * viewPrefs — HOW A FOLDER LOOKS, remembered per folder.
3
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:
4
+ * ⚠⚠ THE RULE, in one sentence, because a rule that cannot be said in one
5
+ * sentence cannot be explained to the person it surprises:
6
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."
7
+ * A change you make in a folder belongs to THAT folder; a folder you have
8
+ * never changed opens with your default view, or the instance's default
9
+ * when you have not set one, or filex's own.
14
10
  *
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.
11
+ * Resolution, field by field (view mode · sort · columns):
22
12
  *
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:
13
+ * the folder's own memory → your default → the instance default
14
+ * → filex's built-in default
25
15
  *
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.
16
+ * ⚠⚠ WHY IT CHANGED, 2026-09-21. The owner, verbatim: "Explore içindeki
17
+ * değişikliklerimiz o klasör özelinde olmalı; tüm klasörlerde görünüm
18
+ * değişikliği geçerli oluyor." Reproduced the same day against the v0.43.0
19
+ * branch: switch folder A to grid, open folder B — B is grid too. Two things
20
+ * made it so, and both were design rather than accident:
29
21
  *
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.
22
+ * 1. the per-folder memory shipped in v0.41.0 as an OPT-IN, off by default,
23
+ * so for nearly everyone nothing was ever remembered per folder at all;
24
+ * 2. every change — with the memory on or off — ALSO wrote a "global
25
+ * default" (`g`), defined as "your last choice becomes the default for
26
+ * every folder you have never set up". The last click anywhere was
27
+ * therefore what every untouched folder showed, which is exactly the leak.
28
+ * Measured with the memory ON: grid in FolderB wrote `f["Repro/FolderB"]`
29
+ * AND `g.v = "grid"`, and FolderA — never touched — opened as grid.
30
+ * This release made it worse by moving `g` onto the account: a click in one
31
+ * folder in one browser re-arranged every untouched folder in every browser.
37
32
  *
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."
33
+ * So: the memory is always on, a change writes ONLY the folder it was made in,
34
+ * and "what an untouched folder opens as" is a DEFAULT the person sets on
35
+ * purpose (their settings) or the operator sets for the instance (admin
36
+ * settings) — never a side effect of a click. Changing a default never touches
37
+ * a folder that remembers its own view; it only changes what untouched folders
38
+ * open as.
43
39
  *
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.
40
+ * ⚠ The columns are per folder too now, and that reverses a decision written
41
+ * here before ("widths are a fact about your SCREEN, so they are global"). The
42
+ * owner's sentence is about "görünüm değişikliği" — every change to how a
43
+ * folder looks — and a column dragged wider in Downloads showing up wider in
44
+ * Photos is the same leak with a different control. A person who does want
45
+ * one arrangement everywhere has "Apply to all folders" in the column menu,
46
+ * which makes this folder's setup their default and forgets the others.
53
47
  *
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.
48
+ * ⚠⚠ WHERE IT IS STORED: the DATABASE, against the user, as one JSON document
49
+ * (migration 00039) — not `localStorage`. The owner's decision, 2026-09-13:
50
+ * "tarayıcıya değil db'ye kaydedeceğiz, basit bir json olarak." Browser
51
+ * storage is per BROWSER, so on a shared machine the second person would
52
+ * silently inherit the first one's folder arrangements.
58
53
  *
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.
54
+ * ⚠ This file holds NO transport. The explorer (and the admin app) inject one
55
+ * (`attachViewPrefsStore`, usually `lib/viewPrefsHttp`), so the module stays
56
+ * testable without a server and an embed with no account degrades to
57
+ * "remember nothing across sessions" rather than to an error.
58
+ *
59
+ * ⚠ Grouping is NOT a separate axis. `ListView` draws date headings exactly
60
+ * when the sort key is `modified`, so "a time-grouped view in this folder" is
61
+ * already what remembering the sort key delivers.
64
62
  */
65
63
  import { ref } from 'vue';
66
64
 
67
65
  import type { SortDir, SortKey } from './sortOrder';
66
+ import {
67
+ columnDropBand as genericDropBand,
68
+ createColumnStore,
69
+ emptyCols,
70
+ readCols,
71
+ reorderColumns as genericReorder,
72
+ type ColsState,
73
+ type ColumnStore,
74
+ type TableColumnSpec,
75
+ } from './tableColumns';
68
76
 
69
77
  export type ViewMode = 'list' | 'grid' | 'gallery';
70
78
 
71
79
  /**
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.
80
+ * The explorer's columns, in the order the list draws them.
81
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.
82
+ * `name` is the table's LEAD: pinned first, never hidden, never moved, but
83
+ * sized by the person like every other column (the owner: "istersem name'i de
84
+ * kısabilir olmalıyım"). The star is a control, not a value: not hideable, not
85
+ * resizable, pinned last beside the row menu.
84
86
  */
85
- export type ColumnId = 'name' | 'type' | 'location' | 'owner' | 'modified' | 'size' | 'star';
87
+ export type ColumnId = 'name' | 'type' | 'location' | 'owner' | 'modified' | 'remaining' | 'size' | 'star';
86
88
 
87
89
  // ── per-folder memory ─────────────────────────────────────────────────
88
90
 
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
- */
91
+ /** One folder's remembered setup. Short keys: the whole map is re-serialised
92
+ * on every write. */
94
93
  export interface FolderPrefs {
95
94
  /** view mode */
96
95
  v?: ViewMode;
@@ -98,6 +97,8 @@ export interface FolderPrefs {
98
97
  k?: SortKey;
99
98
  /** sort direction */
100
99
  d?: SortDir;
100
+ /** this folder's own columns — absent = the default's */
101
+ c?: ColsState<ColumnId>;
101
102
  /** last used, epoch SECONDS — the LRU clock. */
102
103
  t: number;
103
104
  }
@@ -122,21 +123,13 @@ let transport: ViewPrefsTransport | null = null;
122
123
  /**
123
124
  * Has the document resolved yet?
124
125
  *
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.
126
+ * ⚠⚠ Nothing is applied to a folder until it has: a folder whose document has
127
+ * not arrived follows the first-paint cache and is corrected once, exactly
128
+ * when the answer is known. A caller with NO user degrades cleanly: `load`
129
+ * resolves null, `ready` still becomes true, and nothing is ever written.
137
130
  */
138
131
  const ready = ref(false);
139
- /** True when there is somebody to save for. False = read-only, session-only. */
132
+ /** True when there is somebody to save for. False = session-only. */
140
133
  const persistable = ref(false);
141
134
 
142
135
  export function viewPrefsReady(): boolean {
@@ -144,29 +137,20 @@ export function viewPrefsReady(): boolean {
144
137
  }
145
138
 
146
139
  /**
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.
140
+ * ⚠ DEPRECATED — always true. The per-folder memory used to be an opt-in,
141
+ * off by default, and that default was half of the leak the owner reported
142
+ * (see the header). Kept exported so an embedder that read it keeps
143
+ * compiling; a host that genuinely wants no per-folder memory passes
144
+ * `config.rememberFolderView: false` to the explorer, which is the host's
145
+ * decision rather than a hidden switch in a person's settings.
159
146
  */
160
- const memoryOn = ref(false);
161
-
162
147
  export function folderMemoryEnabled(): boolean {
163
- return memoryOn.value;
148
+ return true;
164
149
  }
165
150
 
166
- export function setFolderMemoryEnabled(on: boolean): void {
167
- if (memoryOn.value === on) return;
168
- memoryOn.value = on;
169
- scheduleSave();
151
+ /** ⚠ DEPRECATED — a no-op. See `folderMemoryEnabled`. */
152
+ export function setFolderMemoryEnabled(_on: boolean): void {
153
+ void _on;
170
154
  }
171
155
 
172
156
  type FolderMap = Record<string, FolderPrefs>;
@@ -179,8 +163,20 @@ function isViewMode(v: unknown): v is ViewMode {
179
163
  return v === 'list' || v === 'grid' || v === 'gallery';
180
164
  }
181
165
 
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
166
+ function isSortKey(v: unknown): v is SortKey {
167
+ return v === 'name' || v === 'type' || v === 'modified' || v === 'size';
168
+ }
169
+
170
+ function isSortDir(v: unknown): v is SortDir {
171
+ return v === 'asc' || v === 'desc';
172
+ }
173
+
174
+ /** A column state is worth storing only if it says something. */
175
+ function colsSaySomething(c: ColsState<ColumnId> | undefined): c is ColsState<ColumnId> {
176
+ return !!c && (Object.keys(c.w).length > 0 || c.hidden.length > 0 || (c.o?.length ?? 0) > 0);
177
+ }
178
+
179
+ /** The map, in memory. A ref so the column menu's per-folder rows re-render
184
180
  * when one is written or dropped. */
185
181
  const folderMap = ref<FolderMap>({});
186
182
 
@@ -193,10 +189,14 @@ function readFolderMap(raw: unknown): FolderMap {
193
189
  const t = typeof e.t === 'number' && Number.isFinite(e.t) ? e.t : 0;
194
190
  const entry: FolderPrefs = { t };
195
191
  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;
192
+ if (isSortKey(e.k)) entry.k = e.k;
193
+ if (isSortDir(e.d)) entry.d = e.d;
194
+ if (e.c !== undefined) {
195
+ const c = readCols(e.c, COLUMNS);
196
+ if (colsSaySomething(c)) entry.c = c;
197
+ }
198
198
  // An entry that remembers nothing is noise from a half-written document.
199
- if (entry.v || entry.k) out[k] = entry;
199
+ if (entry.v || entry.k || entry.c) out[k] = entry;
200
200
  }
201
201
  return out;
202
202
  }
@@ -204,32 +204,22 @@ function readFolderMap(raw: unknown): FolderMap {
204
204
  /**
205
205
  * HOW MANY FOLDERS WE REMEMBER, and how the number was chosen.
206
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.
207
+ * ⚠⚠ Without a cap this is one entry per folder ever changed, forever, in a
208
+ * database row replicated into every backup; the endpoint refuses a document
209
+ * over 128 KB, so left uncapped saving would eventually stop working,
210
+ * silently, for the people who use the product most.
213
211
  *
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.
212
+ * 300 is derived: a view-only entry serialises to ~110 bytes and one that also
213
+ * carries its own columns to ~260, so even 300 of the heavier kind is ~78 KB —
214
+ * inside the endpoint's limit with room for longer paths. And the map only
215
+ * gains an entry when somebody CHANGES a folder (walking through one writes
216
+ * nothing), so in real use the cap is a ceiling on the pathological case.
222
217
  */
223
218
  export const FOLDER_CAP = 300;
224
219
 
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
- */
220
+ /** Drop the least-recently-USED entries until the map fits the cap. ⚠ Used,
221
+ * not written: `touchFolder` bumps `t` on open too, so a folder you keep
222
+ * visiting does not age out merely because you have not re-sorted it. */
233
223
  function evict(): void {
234
224
  const entries = Object.entries(folderMap.value);
235
225
  if (entries.length <= FOLDER_CAP) return;
@@ -242,24 +232,13 @@ function evict(): void {
242
232
  /**
243
233
  * THE KEY a folder is remembered under.
244
234
  *
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.
235
+ * ⚠⚠ Read filex issue #21 before changing this. A storage's NAME is editable,
236
+ * so it is not a stable address; every protocol accepts a storage's immutable
237
+ * `uid` as the first path segment for exactly that reason. This takes the
238
+ * storage REF the host gave us — name or uid — and the moment
239
+ * `config.storages[].uid` is populated, keys become rename-proof. Until then
240
+ * renaming a storage loses its folders' memory (they fall back to the
241
+ * default — a mild, self-healing loss).
263
242
  *
264
243
  * The relative path is normalised so `a/b`, `/a/b/` and `a//b` are one folder.
265
244
  */
@@ -270,106 +249,99 @@ export function folderKey(storageRef: string, relPath: string): string {
270
249
  .replace(/\/{2,}/g, '/')
271
250
  .replace(/^\/+|\/+$/g, '');
272
251
  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`. */
252
+ /* ⚠ No trailing slash on a storage ROOT: `thumbfix/` and `thumbfix` would
253
+ * be two keys for one folder — measured, the seeded default for `.recent`
254
+ * never fired because the explorer asked for `.recent/`. */
277
255
  return rel ? `${ref}/${rel}` : ref;
278
256
  }
279
257
 
280
258
  /**
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.
259
+ * The key for the listing of every storage at once (the multi-storage root).
260
+ * It is a place like any other — a person who sorts their drives by name
261
+ * expects them to stay sorted — so it gets a key of its own rather than
262
+ * falling through to "no folder", which would write the person's DEFAULT.
263
+ */
264
+ export const ROOT_FOLDER_KEY = '.root';
265
+
266
+ /**
267
+ * What a folder with NO memory of its own opens as, when the answer is not
268
+ * simply the default. Only Recent is in here: its promise is "the things you
269
+ * were just in", so opening it alphabetically buries the file the person came
270
+ * back for. A SEED, not an override — the moment someone sorts Recent by name,
271
+ * that is stored against `.recent` like any other folder's choice.
292
272
  */
293
273
  const SEEDED: Record<string, Omit<FolderPrefs, 't'>> = {
294
274
  '.recent': { k: 'modified', d: 'desc' },
295
275
  };
296
276
 
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
- */
277
+ /** This folder's OWN memory (or its seed), or null — never a default. */
308
278
  export function folderPrefs(key: string): Omit<FolderPrefs, 't'> | null {
309
279
  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
- }
280
+ const hit = folderMap.value[key];
281
+ if (hit) {
282
+ const { t: _t, ...rest } = hit;
283
+ void _t;
284
+ const seed = SEEDED[key];
285
+ /* A folder that remembers only its view mode keeps its seeded sort. */
286
+ return seed && !rest.k ? { ...seed, ...rest } : rest;
317
287
  }
318
- return SEEDED[key] ?? null;
288
+ return SEEDED[key] ? { ...SEEDED[key] } : null;
319
289
  }
320
290
 
321
- /** True when this folder has a memory the PERSON made (not a seed) — what the
322
- * "Reset this folder" affordance is gated on. */
291
+ /** True when this folder has a memory the PERSON made (not a seed). */
323
292
  export function folderIsRemembered(key: string): boolean {
324
- return memoryOn.value && !!key && !!folderMap.value[key];
293
+ return !!key && !!folderMap.value[key];
325
294
  }
326
295
 
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. */
296
+ /** How many folders are remembered — "Apply to all folders" says so, because
297
+ * an escape hatch that does not say what it throws away is a trap. */
329
298
  export function rememberedCount(): number {
330
- return memoryOn.value ? Object.keys(folderMap.value).length : 0;
299
+ return Object.keys(folderMap.value).length;
331
300
  }
332
301
 
333
302
  /**
334
- * Remember a change against this folder.
303
+ * Remember a change against this folder — ONLY the fields that changed.
335
304
  *
336
305
  * ⚠ 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.
306
+ * `touchFolder`), so the map is the set of folders somebody deliberately set
307
+ * up rather than a log of everywhere they have been.
308
+ *
309
+ * ⚠ A patch, merged. Switching a folder to grid records `v` and nothing else,
310
+ * so the folder's SORT still follows the default: a person who later changes
311
+ * their default sort sees it in every folder whose sort they never touched,
312
+ * including the ones whose view mode they did.
341
313
  */
342
314
  export function rememberFolder(key: string, patch: Omit<FolderPrefs, 't'>): void {
343
- if (!key || !memoryOn.value) return;
315
+ if (!key) return;
344
316
  const prev = folderMap.value[key];
345
- folderMap.value = {
346
- ...folderMap.value,
347
- [key]: { ...(prev ?? {}), ...patch, t: nowSec() },
348
- };
317
+ const next: FolderPrefs = { ...(prev ?? {}), ...patch, t: nowSec() };
318
+ if (patch.c !== undefined && !colsSaySomething(patch.c)) delete next.c;
319
+ if (!next.v && !next.k && !next.c) {
320
+ // Nothing left to remember (a reset of the only thing this folder kept).
321
+ if (prev) forgetFolder(key);
322
+ return;
323
+ }
324
+ folderMap.value = { ...folderMap.value, [key]: next };
349
325
  evict();
350
326
  scheduleSave();
351
327
  }
352
328
 
353
329
  /**
354
330
  * 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.
331
+ * already remembered. ⚠ The guard is the point: bumping an absent key would
332
+ * create an entry for every folder anyone opens.
361
333
  */
362
334
  export function touchFolder(key: string): void {
363
- if (!key || !memoryOn.value) return;
335
+ if (!key) return;
364
336
  const prev = folderMap.value[key];
365
337
  if (!prev) return;
366
338
  const t = nowSec();
367
- if (prev.t === t) return; // same second — nothing to write
339
+ if (prev.t === t) return;
368
340
  folderMap.value = { ...folderMap.value, [key]: { ...prev, t } };
369
341
  scheduleSave();
370
342
  }
371
343
 
372
- /** Forget one folder: it goes back to following the global default. */
344
+ /** Forget one folder: it goes back to following the default. */
373
345
  export function forgetFolder(key: string): void {
374
346
  if (!key || !folderMap.value[key]) return;
375
347
  const next = { ...folderMap.value };
@@ -378,378 +350,456 @@ export function forgetFolder(key: string): void {
378
350
  scheduleSave();
379
351
  }
380
352
 
353
+ /** Forget every folder's own view. */
354
+ export function forgetAllFolders(): void {
355
+ folderMap.value = {};
356
+ scheduleSave();
357
+ }
358
+
359
+ // ── the defaults: the person's, then the instance's ───────────────────
360
+
361
+ /** A person's default folder view. */
362
+ export interface FolderViewDefault {
363
+ v?: ViewMode;
364
+ k?: SortKey;
365
+ d?: SortDir;
366
+ /** default columns (widths, hidden, order) */
367
+ c?: ColsState<ColumnId>;
368
+ }
369
+
370
+ /**
371
+ * The instance's default folder view — the OPERATOR's answer, set in the admin
372
+ * settings (`ui.default_folder_view`) and handed to the client beside the
373
+ * person's document. It carries no widths: a width is about a screen and the
374
+ * operator does not know anybody's; which columns show is a fair instance-wide
375
+ * call, so that part is here.
376
+ */
377
+ export interface InstanceFolderDefault {
378
+ v?: ViewMode;
379
+ k?: SortKey;
380
+ d?: SortDir;
381
+ hidden?: ColumnId[];
382
+ }
383
+
384
+ const personDefault = ref<FolderViewDefault>({});
385
+
386
+ /**
387
+ * ⚠⚠ NOT IN THE DOCUMENT, and that is the lesson the palette taught this same
388
+ * release (web/src/lib/instanceThemes.ts `applyInstanceDefault`): the instance
389
+ * default is the operator's answer, not the person's choice, so APPLYING it
390
+ * must never RECORD it as the person's preference. Kept in its own ref, fed by
391
+ * the server on every load, never saved. A person who has not chosen keeps
392
+ * following the operator — including when the operator changes their mind.
393
+ */
394
+ const instanceDefault = ref<InstanceFolderDefault>({});
395
+
396
+ function normalisePersonDefault(raw: unknown): FolderViewDefault {
397
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
398
+ const p = raw as Record<string, unknown>;
399
+ const out: FolderViewDefault = {};
400
+ if (isViewMode(p.v)) out.v = p.v;
401
+ if (isSortKey(p.k)) {
402
+ out.k = p.k;
403
+ if (isSortDir(p.d)) out.d = p.d;
404
+ }
405
+ if (p.c !== undefined) {
406
+ const c = readCols(p.c, COLUMNS);
407
+ if (colsSaySomething(c)) out.c = c;
408
+ }
409
+ return out;
410
+ }
411
+
412
+ /** Parse the operator's default — an object, or the JSON string the settings
413
+ * table stores. Anything unreadable is "no instance default". */
414
+ export function readInstanceFolderDefault(raw: unknown): InstanceFolderDefault {
415
+ let obj = raw;
416
+ if (typeof raw === 'string') {
417
+ try {
418
+ obj = JSON.parse(raw);
419
+ } catch {
420
+ return {};
421
+ }
422
+ }
423
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return {};
424
+ const p = obj as Record<string, unknown>;
425
+ const out: InstanceFolderDefault = {};
426
+ if (isViewMode(p.v)) out.v = p.v;
427
+ if (isSortKey(p.k)) {
428
+ out.k = p.k;
429
+ if (isSortDir(p.d)) out.d = p.d;
430
+ }
431
+ if (Array.isArray(p.hidden)) {
432
+ const hid = p.hidden.filter(
433
+ (x) => typeof x === 'string' && COLUMNS.some((c) => c.id === x && c.hideable),
434
+ ) as ColumnId[];
435
+ if (hid.length) out.hidden = [...new Set(hid)];
436
+ }
437
+ return out;
438
+ }
439
+
440
+ /** The person's own default, as stored. Reactive. */
441
+ export function personFolderDefault(): FolderViewDefault {
442
+ return personDefault.value;
443
+ }
444
+
445
+ /**
446
+ * Set parts of the person's default; an `undefined` or `null` field CLEARS
447
+ * that part, so it follows the instance default again.
448
+ *
449
+ * ⚠ Never called by a click in a folder — only by the settings control and by
450
+ * "Apply to all folders". That is the whole fix.
451
+ */
452
+ export function setPersonFolderDefault(
453
+ patch: Partial<Record<keyof FolderViewDefault, unknown>>,
454
+ ): void {
455
+ const next: Record<string, unknown> = { ...personDefault.value };
456
+ for (const [k, v] of Object.entries(patch)) {
457
+ if (v === undefined || v === null) delete next[k];
458
+ else next[k] = v;
459
+ }
460
+ personDefault.value = normalisePersonDefault(next);
461
+ mirrorDefault();
462
+ scheduleSave();
463
+ }
464
+
381
465
  /**
382
466
  * THE ESCAPE HATCH — "Apply to all folders".
383
467
  *
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.
468
+ * Makes the setup on screen the person's DEFAULT and forgets every folder's
469
+ * own memory, so every folder now opens the way this one does — which is what
470
+ * the words promise. ⚠ It has to WRITE the default now: the old model got this
471
+ * for free because every click already wrote a global default, and that free
472
+ * write is the leak this file exists to close.
388
473
  */
389
- export function forgetAllFolders(): void {
474
+ export function applyToAllFolders(view: FolderViewDefault): void {
475
+ personDefault.value = normalisePersonDefault({ ...personDefault.value, ...view });
390
476
  folderMap.value = {};
477
+ mirrorDefault();
391
478
  scheduleSave();
392
479
  }
393
480
 
394
- // ── columns: widths + visibility (global) ─────────────────────────────
481
+ /** The operator's default. Reactive. */
482
+ export function instanceFolderDefault(): InstanceFolderDefault {
483
+ return instanceDefault.value;
484
+ }
395
485
 
486
+ /** Hand the module the operator's default (the transport does, on load). */
487
+ export function setInstanceFolderDefault(raw: unknown): void {
488
+ instanceDefault.value = readInstanceFolderDefault(raw);
489
+ mirrorDefault();
490
+ }
396
491
 
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;
492
+ /**
493
+ * What an UNTOUCHED folder opens as — the person's default, else the
494
+ * instance's, field by field. `undefined` fields mean "filex's built-in
495
+ * default", which the caller owns (the explorer's host may pass its own
496
+ * `config.viewMode`).
497
+ *
498
+ * ⚠ Sort is resolved as a PAIR: a person who chose "Size" and an operator
499
+ * who chose "Modified ↓" must not produce "Size ↓".
500
+ */
501
+ export function defaultFolderView(): { v?: ViewMode; k?: SortKey; d?: SortDir } {
502
+ const p = personDefault.value;
503
+ const i = instanceDefault.value;
504
+ const out: { v?: ViewMode; k?: SortKey; d?: SortDir } = {};
505
+ const v = p.v ?? i.v;
506
+ if (v) out.v = v;
507
+ if (p.k) {
508
+ out.k = p.k;
509
+ if (p.d) out.d = p.d;
510
+ } else if (i.k) {
511
+ out.k = i.k;
512
+ if (i.d) out.d = i.d;
513
+ }
514
+ return out;
407
515
  }
408
516
 
517
+ /**
518
+ * The folder's view, resolved: its own memory (or seed), then the defaults.
519
+ * View mode and sort resolve independently — a folder that only remembers
520
+ * "grid" still follows the default sort.
521
+ */
522
+ export function resolveFolderView(key: string): { v?: ViewMode; k?: SortKey; d?: SortDir } {
523
+ const own = folderPrefs(key);
524
+ const def = defaultFolderView();
525
+ const out: { v?: ViewMode; k?: SortKey; d?: SortDir } = {};
526
+ const v = own?.v ?? def.v;
527
+ if (v) out.v = v;
528
+ if (own?.k) {
529
+ out.k = own.k;
530
+ if (own.d) out.d = own.d;
531
+ } else if (def.k) {
532
+ out.k = def.k;
533
+ if (def.d) out.d = def.d;
534
+ }
535
+ return out;
536
+ }
537
+
538
+ // ── the columns ───────────────────────────────────────────────────────
539
+
540
+ export type ColumnSpec = TableColumnSpec<ColumnId>;
541
+
409
542
  /**
410
543
  * The row's tracks, in shipped order.
411
544
  *
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.
545
+ * ⚠⚠ There is no `priority` field and nothing is shed for width: the owner
546
+ * rejected a table that dropped columns and said "no room" — "kenara devam
547
+ * eden bir scroll getirsin". A table wider than its pane scrolls sideways.
423
548
  */
424
549
  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
550
  { id: 'name', width: 260, min: 120, max: 900, hideable: false, resizable: true },
428
551
  { id: 'type', width: 88, min: 64, max: 220, hideable: true, resizable: true },
429
552
  { id: 'location', width: 160, min: 80, max: 420, hideable: true, resizable: true },
430
553
  { id: 'owner', width: 104, min: 72, max: 260, hideable: true, resizable: true },
431
554
  { id: 'modified', width: 160, min: 96, max: 300, hideable: true, resizable: true },
555
+ /* The Trash only: how long an item has left before it is purged. */
556
+ { id: 'remaining', width: 112, min: 80, max: 220, hideable: true, resizable: true },
432
557
  { 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
558
  { id: 'star', width: 24, min: 24, max: 24, hideable: false, resizable: false },
438
559
  ] as const;
439
560
 
440
561
  /** The two fixed tracks, in px, so the table arithmetic can be exact. */
441
562
  export const FIXED_TRACKS = { check: 28, menu: 28 } as const;
442
563
 
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
- */
564
+ /** How narrow a person may drag Name: the tile, its gap and ~11 characters. */
456
565
  export const NAME_MIN = 120;
457
566
 
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
- */
567
+ /** What Name opens at when nobody has sized anything — ~26 characters at
568
+ * 13px/500, enough for `Quarterly Report Q2.pdf` to read whole. */
468
569
  export const NAME_AUTO = 220;
469
570
 
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
- }
571
+ /** The explorer's table metrics — a tick column, the 28px ⋮, Name opening at
572
+ * `NAME_AUTO` — handed to `ColumnStore.layout` by the list view. */
573
+ export const EXPLORER_METRICS = {
574
+ gap: 8,
575
+ padding: 24,
576
+ check: FIXED_TRACKS.check,
577
+ menu: FIXED_TRACKS.menu,
578
+ leadAuto: NAME_AUTO,
579
+ } as const;
512
580
 
513
- const colsState = ref<ColsState>(readCols(null));
581
+ /**
582
+ * The column state a folder with no columns of its own shows: the person's
583
+ * default columns, else the instance's hidden set, else the shipped layout.
584
+ */
585
+ function defaultCols(): ColsState<ColumnId> {
586
+ if (personDefault.value.c) return personDefault.value.c;
587
+ const hid = instanceDefault.value.hidden;
588
+ return hid && hid.length ? { w: {}, hidden: [...hid] } : emptyCols<ColumnId>();
589
+ }
514
590
 
515
591
  /**
516
- * THE COLUMN ORDER — the person's, reconciled with the columns that exist.
592
+ * THE EXPLORER'S COLUMN STORE for one folder — the generic store
593
+ * (`lib/tableColumns`) over the explorer's columns, backed by this folder's
594
+ * memory with the default beneath it.
517
595
  *
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.
596
+ * ⚠ `''` is not a folder: it is the person's DEFAULT layout. A listing reaches
597
+ * it only when its host switched the per-folder memory off
598
+ * (`config.rememberFolderView: false`), and for such a host "one arrangement
599
+ * everywhere" is exactly what it asked for.
525
600
  *
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.
601
+ * ⚠ "Reset columns" in a folder means "follow my default here again": the
602
+ * store writes an empty state and `rememberFolder` turns that into dropping
603
+ * the folder's `c`, not into pinning the SHIPPED layout on this folder.
534
604
  */
605
+ export function folderColumnStore(keyOf: () => string): ColumnStore<ColumnId> {
606
+ return createColumnStore<ColumnId>(
607
+ () => COLUMNS,
608
+ {
609
+ get: () => {
610
+ const key = keyOf();
611
+ if (!key) return defaultCols();
612
+ return folderMap.value[key]?.c ?? defaultCols();
613
+ },
614
+ set: (next) => {
615
+ const key = keyOf();
616
+ if (!key) {
617
+ setPersonFolderDefault({ c: colsSaySomething(next) ? next : undefined });
618
+ return;
619
+ }
620
+ rememberFolder(key, { c: next });
621
+ },
622
+ customised: () => {
623
+ const key = keyOf();
624
+ if (!key) return colsSaySomething(personDefault.value.c);
625
+ return !!folderMap.value[key]?.c;
626
+ },
627
+ },
628
+ { lead: 'name' },
629
+ );
630
+ }
631
+
632
+ /**
633
+ * The person's DEFAULT layout as a store — what the module-level column
634
+ * functions below operate on (a host with the per-folder memory off, and the
635
+ * tests that drive the arithmetic). Created once, for the life of the module.
636
+ */
637
+ const defaultColumns = folderColumnStore(() => '');
638
+
535
639
  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;
640
+ return defaultColumns.order();
641
+ }
642
+
643
+ /** THE REORDER ARITHMETIC — `lib/tableColumns.reorderColumns`, re-exported. */
644
+ export const reorderColumns = genericReorder;
645
+
646
+ /** The stretch of an order a movable explorer column may occupy. */
647
+ export function columnDropBand(order: readonly ColumnId[]): { lo: number; hi: number } {
648
+ return genericDropBand(order, COLUMNS);
554
649
  }
555
650
 
556
- /** Put `id` at `index` in the order. Everything else closes up behind it. */
557
651
  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();
652
+ defaultColumns.move(id, index);
571
653
  }
572
654
 
573
- /** One step left or right — what the header menu offers, so the whole gesture
574
- * is reachable without a pointer. */
655
+ /** One step left or right among the movable columns of the default layout. */
575
656
  export function moveColumnBy(id: ColumnId, delta: -1 | 1): void {
576
- const cur = columnOrder().filter((c) => specOf(c)?.hideable);
657
+ const cur = columnOrder().filter((c) => COLUMNS.find((s) => s.id === c)?.hideable);
577
658
  const at = cur.indexOf(id);
578
659
  if (at === -1) return;
579
660
  const to = at + delta;
580
661
  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
662
  const neighbour = cur[to];
585
- const full = columnOrder();
586
- moveColumn(id, full.indexOf(neighbour));
663
+ defaultColumns.move(id, columnOrder().indexOf(neighbour));
587
664
  }
588
665
 
589
- /** Can this column still move that way? The menu greys the ends rather than
590
- * offering a button that does nothing. */
591
666
  export function canMoveColumn(id: ColumnId, delta: -1 | 1): boolean {
592
- const cur = columnOrder().filter((c) => specOf(c)?.hideable);
667
+ const cur = columnOrder().filter((c) => COLUMNS.find((s) => s.id === c)?.hideable);
593
668
  const at = cur.indexOf(id);
594
669
  if (at === -1) return false;
595
670
  return at + delta >= 0 && at + delta < cur.length;
596
671
  }
597
672
 
598
- /** This column's width right now — the person's, or the default. */
599
673
  export function columnWidth(id: ColumnId): number {
600
- const spec = specOf(id);
601
- if (!spec) return 0;
602
- return colsState.value.w[id] ?? spec.width;
674
+ return defaultColumns.width(id);
603
675
  }
604
676
 
605
- /** Set a width, clamped to the column's own bounds. */
606
677
  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();
678
+ defaultColumns.setWidth(id, px);
613
679
  }
614
680
 
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
681
  export function widthsAreAuto(): boolean {
638
- return colsState.value.w.name === undefined;
682
+ return defaultColumns.widthsAreAuto();
639
683
  }
640
684
 
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
685
  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();
686
+ defaultColumns.freeze(widths);
668
687
  }
669
688
 
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
689
  export function columnHidden(id: ColumnId): boolean {
673
- return colsState.value.hidden.includes(id);
690
+ return defaultColumns.hidden(id);
674
691
  }
675
692
 
676
693
  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();
694
+ defaultColumns.setHidden(id, hidden);
685
695
  }
686
696
 
687
- /** Back to the shipped widths, order and visibility — one button for all
688
- * three, because "reset the columns" is one thought. */
689
697
  export function resetColumns(): void {
690
- colsState.value = { w: {}, hidden: [] };
691
- scheduleSave();
698
+ defaultColumns.reset();
692
699
  }
693
700
 
694
- /** True when anything about the columns has been changed by hand — what the
695
- * header menu's "Reset columns" is gated on. */
696
701
  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
+ return defaultColumns.customised();
702
703
  }
703
704
 
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.
705
+ /** What one explorer table looks like right now. */
706
+ export interface TableLayout {
707
+ visible: ColumnId[];
708
+ widths: Record<ColumnId, number>;
709
+ total: number;
710
+ auto: boolean;
711
+ }
712
+
713
+ /** The explorer's table arithmetic over the DEFAULT layout. A folder's own
714
+ * layout is `folderColumnStore(key).layout(…, EXPLORER_METRICS)`. */
715
+ export function tableLayout(
716
+ available: number,
717
+ candidates: readonly ColumnId[],
718
+ opts: { gap?: number; padding?: number } = {},
719
+ ): TableLayout {
720
+ return defaultColumns.layout(available, candidates, { ...EXPLORER_METRICS, ...opts });
721
+ }
722
+
723
+ // ── the first-paint cache ─────────────────────────────────────────────
724
+
725
+ /**
726
+ * The localStorage names the FIRST PAINT reads (FileExplorer's view mode,
727
+ * lib/sortOrder's sort). ⚠ Caches of the resolved DEFAULT, never the answer:
728
+ * the document cannot arrive before the first frame, and a listing that paints
729
+ * name-ascending and then jumps would flash. ⚠⚠ Written from the DEFAULT only,
730
+ * never from a click — a click in folder A written here would paint folder B
731
+ * in A's view on the next load before the document corrected it, which is the
732
+ * leak again for half a second.
715
733
  */
734
+ const MIRRORS = {
735
+ sort: 'filex.list-sort',
736
+ view: 'brf-file-explorer:view-mode',
737
+ } as const;
738
+
739
+ function mirrorDefault(): void {
740
+ const d = defaultFolderView();
741
+ try {
742
+ if (d.k) {
743
+ const dir = d.d ?? (d.k === 'modified' ? 'desc' : 'asc');
744
+ localStorage.setItem(MIRRORS.sort, JSON.stringify({ key: d.k, dir }));
745
+ } else localStorage.removeItem(MIRRORS.sort);
746
+ if (d.v) localStorage.setItem(MIRRORS.view, d.v);
747
+ else localStorage.removeItem(MIRRORS.view);
748
+ } catch {
749
+ /* private mode / quota — the first paint falls back to the product's
750
+ default until the document lands */
751
+ }
752
+ }
753
+
754
+ // ── the document ─────────────────────────────────────────────────────
755
+ //
756
+ // { "f": { "<storage>/<rel>": {v,k,d,c,t}, … }, per folder, LRU-capped
757
+ // "p": { v, k, d, c }, the person's default
758
+ // "u": <epoch ms>, last write
759
+ // "on": true } for older clients (below)
760
+ //
761
+ // ⚠ RETIRED KEYS, read once and never written again:
762
+ // · `g` — the old "global default", written by every click. It is NOT
763
+ // carried into `p`: it was never a choice anybody made on purpose, it was
764
+ // their last click anywhere, and promoting it to "your default" would ship
765
+ // the leak frozen into the settings screen. Untouched folders open with
766
+ // the instance's or filex's default until the person sets their own.
767
+ // · `c` — the old global columns. These ARE carried into `p.c` once: a
768
+ // column width was only ever changed by a deliberate drag, and dropping
769
+ // them would throw away work the person did on purpose.
770
+ // · `on` — the opt-in switch. Always written `true` so an older client (a
771
+ // desktop app not yet updated) still honours the per-folder memory it
772
+ // finds in the same document.
716
773
 
717
774
  interface ViewPrefsDoc {
718
775
  on?: boolean;
719
776
  f?: unknown;
777
+ p?: unknown;
720
778
  c?: unknown;
779
+ g?: unknown;
780
+ u?: unknown;
721
781
  [ns: string]: unknown;
722
782
  }
723
783
 
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;
784
+ /** The top-level names this module owns — retired ones included, so a slot
785
+ * can never claim them and they are not carried through as somebody else's. */
786
+ const OWNED_KEYS = ['on', 'f', 'p', 'c', 'g', 'u'] as const;
727
787
 
728
788
  /**
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.
789
+ * When this session last WROTE the document, epoch milliseconds — what "the
790
+ * last thing I did wins" across browsers is made of. A refresh that finds a
791
+ * NEWER stamp applies the server's document; an older or equal one keeps what
792
+ * is on screen.
793
+ */
794
+ const stamp = ref(0);
795
+
796
+ /**
797
+ * ⚠⚠ EVERYTHING IN THE DOCUMENT THAT IS NOT OURS, kept verbatim. The endpoint
798
+ * stores an opaque object; a save that rebuilt only our keys once erased the
799
+ * desktop-app reminder's "never show me again" on the next column drag.
749
800
  */
750
801
  const foreign = ref<Record<string, unknown>>({});
751
802
 
752
- /** Split a loaded document into "ours" and "everybody else's". */
753
803
  function takeForeign(doc: Record<string, unknown>): Record<string, unknown> {
754
804
  const rest: Record<string, unknown> = {};
755
805
  for (const [k, v] of Object.entries(doc)) {
@@ -760,47 +810,38 @@ function takeForeign(doc: Record<string, unknown>): Record<string, unknown> {
760
810
  }
761
811
 
762
812
  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 };
813
+ /* ⚠ Spread FIRST, so the keys this module owns always win. */
814
+ const doc: ViewPrefsDoc = {
815
+ ...foreign.value,
816
+ on: true,
817
+ f: folderMap.value,
818
+ u: stamp.value,
819
+ };
820
+ if (Object.keys(personDefault.value).length) doc.p = personDefault.value;
821
+ return doc;
766
822
  }
767
823
 
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
- */
824
+ /* ── namespaced slots: room in the document for somebody else ──────────── */
783
825
 
784
826
  /** One feature's corner of the document. */
785
827
  export interface ViewPrefsSlot<T> {
786
828
  /** What is stored, or undefined — including before the document lands. */
787
829
  get(): T | undefined;
788
- /** Replace it. A no-op when there is nobody to save for. */
830
+ /** Replace it. Session-only when there is nobody to save for. */
789
831
  set(value: T): void;
790
832
  /** Remove the key entirely. */
791
833
  clear(): void;
792
- /** Has the document been read yet? Reactive — read it in a `computed`. */
834
+ /** Has the document been read yet? Reactive. */
793
835
  ready(): boolean;
794
836
  /** Is there an account behind this session at all? Reactive. */
795
837
  persistable(): boolean;
796
838
  }
797
839
 
798
840
  /**
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.
841
+ * Claim `namespace` as a top-level key in the per-user document. ⚠ The names
842
+ * this module owns are refused outright rather than quietly renamed: a slot
843
+ * called `c` that silently became `c2` would be a value the caller could never
844
+ * read back.
804
845
  */
805
846
  export function viewPrefsSlot<T>(namespace: string): ViewPrefsSlot<T> {
806
847
  if (!namespace || (OWNED_KEYS as readonly string[]).includes(namespace)) {
@@ -825,26 +866,19 @@ export function viewPrefsSlot<T>(namespace: string): ViewPrefsSlot<T> {
825
866
  }
826
867
 
827
868
  /**
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.
869
+ * SAVING — debounced, coalesced, fire and forget. A resize is dozens of writes
870
+ * per second; waiting for the gesture to settle and then sending the WHOLE
871
+ * document makes the last write the true one by construction, and a dropped
872
+ * write self-healing.
838
873
  */
839
874
  const SAVE_DEBOUNCE_MS = 800;
840
875
  let saveTimer: ReturnType<typeof setTimeout> | undefined;
841
876
  let saveDirty = false;
842
877
 
843
878
  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. */
879
+ /* ⚠ Not before the document has loaded: a save fired while the fetch is in
880
+ * flight would write this session's empty defaults over everything the
881
+ * person had arranged — the classic "my settings reset themselves". */
848
882
  if (!ready.value || !persistable.value || !transport) return;
849
883
  saveDirty = true;
850
884
  if (saveTimer) clearTimeout(saveTimer);
@@ -856,17 +890,14 @@ function flushSave(): void {
856
890
  saveTimer = undefined;
857
891
  if (!saveDirty || !transport || !persistable.value) return;
858
892
  saveDirty = false;
893
+ // ⚠ Stamped at SEND time: the stamp answers "whose copy is newer".
894
+ stamp.value = Date.now();
859
895
  transport.save(currentDoc());
860
896
  }
861
897
 
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
- */
898
+ /* ⚠ A change in the last moment before a tab closes is inside the debounce
899
+ * window; `pagehide` is the last event a browser reliably delivers, and the
900
+ * transport sends it with `keepalive`. */
870
901
  if (typeof window !== 'undefined') {
871
902
  window.addEventListener('pagehide', flushSave);
872
903
  window.addEventListener('visibilitychange', () => {
@@ -875,162 +906,167 @@ if (typeof window !== 'undefined') {
875
906
  }
876
907
 
877
908
  /**
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.
909
+ * Hand this module its transport and read the document. Calling it again is a
910
+ * no-op: two explorers on one page share this module's state, and the admin
911
+ * app attaches before the explorer it hosts does.
883
912
  */
884
913
  export function attachViewPrefsStore(t: ViewPrefsTransport): void {
885
914
  if (transport) return;
886
915
  transport = t;
887
916
  void (async () => {
888
- let doc: unknown = null;
917
+ await readDoc(true);
918
+ ready.value = true;
919
+ watchForFreshDocuments();
920
+ })();
921
+ }
922
+
923
+ /**
924
+ * Forget the account this module was reading for — sign-out. The next
925
+ * `attachViewPrefsStore` reads the next person's document from scratch, and
926
+ * nothing of the previous person's arrangement is left on screen for them.
927
+ */
928
+ export function detachViewPrefsStore(): void {
929
+ flushSave();
930
+ resetState();
931
+ }
932
+
933
+ /** Is a transport attached? */
934
+ export function viewPrefsAttached(): boolean {
935
+ return transport !== null;
936
+ }
937
+
938
+ const applied = new Set<() => void>();
939
+
940
+ /** Be told when the stored preferences have been applied (the first load and
941
+ * every refresh that wins). */
942
+ export function onViewPrefsApplied(fn: () => void): () => void {
943
+ applied.add(fn);
944
+ return () => applied.delete(fn);
945
+ }
946
+
947
+ function announceApplied(): void {
948
+ for (const fn of applied) {
889
949
  try {
890
- doc = await t.load();
950
+ fn();
891
951
  } 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;
952
+ /* one surface's mistake is not another's */
896
953
  }
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;
954
+ }
955
+ }
956
+
957
+ /**
958
+ * Read the document and apply it — unless what is on screen is newer.
959
+ *
960
+ * ⚠⚠ `first` is the mount. Afterwards this is the FRESHNESS half: a tab that
961
+ * read the document once at boot never sees what the other browser did, so
962
+ * the refresh runs when the tab comes back to the front.
963
+ */
964
+ async function readDoc(first: boolean): Promise<void> {
965
+ if (!transport) return;
966
+ let doc: unknown = null;
967
+ try {
968
+ doc = await transport.load();
969
+ } catch {
970
+ /* No account, offline, a 500 — all one answer: remember nothing this
971
+ session and never write, so a transient failure cannot erase what is
972
+ already stored. */
973
+ doc = null;
974
+ }
975
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
976
+ // Still tell the surfaces: the instance default may have arrived with it.
977
+ mirrorDefault();
978
+ announceApplied();
979
+ return;
980
+ }
981
+ const d = doc as ViewPrefsDoc;
982
+ const theirs = typeof d.u === 'number' && Number.isFinite(d.u) ? d.u : 0;
983
+
984
+ if (!first) {
985
+ // ⚠ A change this session has not sent yet OUTRANKS the server's copy:
986
+ // the person is mid-gesture and their own tab must not be overwritten by
987
+ // what they did five minutes ago somewhere else.
988
+ if (saveDirty) {
989
+ flushSave();
990
+ return;
907
991
  }
992
+ // ⚠ Strictly newer. Equal stamps mean "our own document coming back".
993
+ if (theirs <= stamp.value) return;
994
+ }
995
+
996
+ folderMap.value = readFolderMap(d.f);
997
+ let migrated = false;
998
+ if (d.p !== undefined) {
999
+ personDefault.value = normalisePersonDefault(d.p);
1000
+ } else {
1001
+ /* ONE-TIME MIGRATION of the retired global columns (see the note above
1002
+ the document shape): a deliberate drag is kept as the person's default
1003
+ columns. The retired `g` is deliberately NOT migrated. */
1004
+ const c = readCols(d.c, COLUMNS);
1005
+ personDefault.value = colsSaySomething(c) ? { c } : {};
1006
+ migrated = colsSaySomething(c) || d.g !== undefined || d.c !== undefined;
1007
+ }
1008
+ stamp.value = theirs;
1009
+ /* ⚠ Whatever else was in there travels on untouched. */
1010
+ foreign.value = takeForeign(d as Record<string, unknown>);
1011
+ persistable.value = true;
1012
+ mirrorDefault();
1013
+ if (migrated) {
1014
+ /* Rewrite the document in the new shape once, so the retired `g` stops
1015
+ travelling. ⚠ `ready` before `scheduleSave`, whose guard exists to stop
1016
+ a write landing while the fetch is out — and the fetch just finished. */
908
1017
  ready.value = true;
909
- })();
1018
+ scheduleSave();
1019
+ }
1020
+ announceApplied();
910
1021
  }
911
1022
 
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 {
1023
+ const REFRESH_THROTTLE_MS = 3_000;
1024
+ let lastRefresh = 0;
1025
+ let watching = false;
1026
+
1027
+ /** Re-read the document (throttled) — when a tab comes back to the front. */
1028
+ export function refreshViewPrefs(force = false): void {
1029
+ if (!transport || !ready.value) return;
1030
+ const now = Date.now();
1031
+ if (!force && now - lastRefresh < REFRESH_THROTTLE_MS) return;
1032
+ lastRefresh = now;
1033
+ void readDoc(false);
1034
+ }
1035
+
1036
+ function watchForFreshDocuments(): void {
1037
+ if (watching || typeof window === 'undefined') return;
1038
+ watching = true;
1039
+ window.addEventListener('focus', () => refreshViewPrefs());
1040
+ if (typeof document !== 'undefined') {
1041
+ document.addEventListener('visibilitychange', () => {
1042
+ if (document.visibilityState === 'visible') refreshViewPrefs();
1043
+ });
1044
+ }
1045
+ }
1046
+
1047
+ function resetState(): void {
915
1048
  transport = null;
916
1049
  ready.value = false;
917
1050
  persistable.value = false;
918
- memoryOn.value = false;
919
1051
  folderMap.value = {};
920
- colsState.value = readCols(null);
1052
+ personDefault.value = {};
1053
+ instanceDefault.value = {};
1054
+ stamp.value = 0;
1055
+ lastRefresh = 0;
921
1056
  foreign.value = {};
922
1057
  saveDirty = false;
923
1058
  if (saveTimer) clearTimeout(saveTimer);
924
1059
  saveTimer = undefined;
925
1060
  }
926
1061
 
1062
+ /** Test seam: drop the transport and the state, so one suite cannot leak a
1063
+ * document into the next. Not used by the app. */
1064
+ export function __resetViewPrefs(): void {
1065
+ resetState();
1066
+ applied.clear();
1067
+ }
1068
+
927
1069
  /** Test seam: send whatever is pending now, without waiting out the debounce. */
928
1070
  export function __flushViewPrefs(): void {
929
1071
  flushSave();
930
1072
  }
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
- }