@brftech/filex-core 0.42.2 → 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.
- package/README.md +14 -2
- package/dist/ArchiveViewer-BqU761Wk.js +149 -0
- package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
- package/dist/CsvViewer-ByHVK5ga.js +172 -0
- package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
- package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
- package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
- package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
- package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
- package/dist/MermaidViewer-CEDTD_pp.js +136 -0
- package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
- package/dist/PsdViewer-CZcxUpZO.js +121 -0
- package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
- package/dist/TiffViewer-BwI62CbQ.js +141 -0
- package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
- package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
- package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
- package/dist/filex-core.js +475 -219
- package/dist/filex-core.umd.cjs +165 -108
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/fonts/caveat-latin-ext.woff2 +0 -0
- package/dist/fonts/caveat-latin.woff2 +0 -0
- package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/dist/fonts/dancing-script-latin.woff2 +0 -0
- package/dist/fonts/homemade-apple-latin.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin.woff2 +0 -0
- package/dist/index-DuWnGuv5.js +35901 -0
- package/dist/index-DuWnGuv5.js.map +1 -0
- package/dist/index.d.ts +5227 -553
- package/dist/pdf-CaPYsOUo.js +21302 -0
- package/dist/pdf-CaPYsOUo.js.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1055 -253
- package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
- package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
- package/src/assets/fonts/README.md +58 -0
- package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
- package/src/assets/fonts/caveat-latin.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
- package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
- package/src/components/AdvancedSearch.vue +49 -0
- package/src/components/Breadcrumb.vue +5 -5
- package/src/components/ChoiceButtons.vue +152 -0
- package/src/components/CommandPalette.vue +5 -5
- package/src/components/ConnectionGuideView.vue +1 -1
- package/src/components/ConnectionNotice.vue +59 -0
- package/src/components/ConnectionsPanel.vue +30 -568
- package/src/components/ContextMenu.vue +31 -5
- package/src/components/DataTable.vue +1331 -0
- package/src/components/FilePane.vue +3 -0
- package/src/components/FilterBar.vue +12 -5
- package/src/components/GalleryView.vue +50 -2
- package/src/components/GridView.vue +51 -3
- package/src/components/HomeView.vue +18 -6
- package/src/components/InspectorPanel.vue +134 -17
- package/src/components/ListView.vue +366 -900
- package/src/components/LogoMark.vue +57 -0
- package/src/components/NFSExportsPanel.vue +76 -28
- package/src/components/OnboardingTour.vue +19 -7
- package/src/components/OperationsCenter.vue +77 -1
- package/src/components/PendingOpsTray.vue +59 -5
- package/src/components/PresenceBar.vue +7 -7
- package/src/components/ProductVersion.vue +31 -0
- package/src/components/QuickLook.vue +10 -14
- package/src/components/RecentlyOpened.vue +31 -15
- package/src/components/RecoveryKeyModal.vue +1 -1
- package/src/components/RowActions.vue +134 -0
- package/src/components/S3KeysPanel.vue +98 -62
- package/src/components/SSHKeysPanel.vue +79 -30
- package/src/components/SideNav.vue +260 -96
- package/src/components/StarButton.vue +1 -0
- package/src/components/StorageFields.vue +74 -6
- package/src/components/StorageTags.vue +62 -0
- package/src/components/TabBar.vue +1 -1
- package/src/components/TagKindIcon.vue +56 -0
- package/src/components/TagPicker.vue +226 -40
- package/src/components/ThemePalette.vue +4 -4
- package/src/components/TimeZonePicker.vue +29 -10
- package/src/components/TokensPanel.vue +82 -31
- package/src/components/Toolbar.vue +28 -6
- package/src/components/plugin/PluginConfirmModal.vue +54 -0
- package/src/components/plugin/PluginInspectorSection.vue +149 -0
- package/src/components/plugin/PluginPageView.vue +339 -0
- package/src/components/plugin/PluginViewModal.vue +159 -0
- package/src/components/plugin/SurfaceConversation.vue +76 -0
- package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
- package/src/components/plugin/SurfaceRenderer.vue +299 -0
- package/src/components/plugin/SurfaceSections.vue +91 -0
- package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
- package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
- package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
- package/src/components/plugin/nodes/SurfaceList.vue +127 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
- package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
- package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
- package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
- package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
- package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
- package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
- package/src/components/public/PublicLanguagePicker.vue +58 -0
- package/src/components/public/PublicLinkPage.vue +286 -0
- package/src/components/public/PublicLinkPreview.vue +85 -0
- package/src/components/public/PublicPinGate.vue +108 -0
- package/src/components/public/PublicRequestBody.vue +204 -0
- package/src/components/public/PublicShareBody.vue +188 -0
- package/src/components/public/PublicShell.vue +332 -0
- package/src/composables/useConnections.ts +8 -2
- package/src/composables/useFileApi.ts +183 -44
- package/src/composables/useLocale.ts +277 -41
- package/src/composables/useNFSExports.ts +8 -10
- package/src/composables/useOperations.ts +44 -3
- package/src/composables/usePendingOps.ts +59 -8
- package/src/composables/usePluginActions.ts +121 -0
- package/src/composables/usePluginSurface.ts +223 -0
- package/src/composables/usePublicBranding.ts +172 -0
- package/src/composables/usePublicLink.ts +573 -0
- package/src/composables/usePublicPage.ts +61 -0
- package/src/composables/useS3Keys.ts +17 -12
- package/src/composables/useSSHKeys.ts +8 -10
- package/src/composables/useTokens.ts +8 -12
- package/src/composables/useUploadChunked.ts +18 -8
- package/src/composables/useViewerFetch.ts +6 -3
- package/src/index.ts +404 -8
- package/src/lib/actionIcons.ts +46 -1
- package/src/lib/advSearch.ts +14 -7
- package/src/lib/appLock.ts +130 -0
- package/src/lib/connection.ts +110 -0
- package/src/lib/destinationTree.ts +19 -3
- package/src/lib/direction.ts +353 -0
- package/src/lib/dragGhost.ts +6 -0
- package/src/lib/dropLimits.ts +69 -0
- package/src/lib/errorWords.ts +394 -0
- package/src/lib/externalReach.ts +5 -1
- package/src/lib/fileFilters.ts +7 -2
- package/src/lib/fileIcons.ts +4 -0
- package/src/lib/internalPaths.ts +77 -0
- package/src/lib/listInput.ts +64 -0
- package/src/lib/listing.ts +58 -23
- package/src/lib/modalStack.ts +26 -0
- package/src/lib/nodeRow.ts +18 -3
- package/src/lib/pdfFieldRules.ts +126 -0
- package/src/lib/pdfFields.ts +589 -0
- package/src/lib/pdfFieldsGeom.ts +203 -0
- package/src/lib/pdfjsLoader.ts +51 -0
- package/src/lib/personName.ts +47 -0
- package/src/lib/pluginApplies.ts +123 -0
- package/src/lib/pluginLabel.ts +72 -0
- package/src/lib/pluginMenu.ts +253 -0
- package/src/lib/pluginPage.ts +58 -0
- package/src/lib/plural.ts +181 -0
- package/src/lib/popupLayer.ts +103 -0
- package/src/lib/prefs.ts +563 -0
- package/src/lib/productVersion.ts +35 -0
- package/src/lib/publicLayout.ts +39 -0
- package/src/lib/serviceGate.ts +104 -0
- package/src/lib/shareTtl.ts +25 -11
- package/src/lib/signFonts.ts +85 -0
- package/src/lib/signaturePad.ts +111 -0
- package/src/lib/sortOrder.ts +100 -38
- package/src/lib/star.ts +7 -1
- package/src/lib/surfaceCell.ts +41 -0
- package/src/lib/surfaceConditions.ts +156 -0
- package/src/lib/surfaceOpen.ts +106 -0
- package/src/lib/surfaceSteps.ts +68 -0
- package/src/lib/surfaceValues.ts +191 -0
- package/src/lib/symlink.ts +150 -0
- package/src/lib/tableColumns.ts +493 -0
- package/src/lib/tableEnv.ts +34 -0
- package/src/lib/tablePrefs.ts +84 -0
- package/src/lib/tags.ts +121 -12
- package/src/lib/themes.ts +286 -18
- package/src/lib/tour.ts +152 -0
- package/src/lib/trashEmpty.ts +111 -0
- package/src/lib/trashTimeLeft.ts +23 -0
- package/src/lib/uiLocales.ts +460 -0
- package/src/lib/viewPrefs.ts +696 -660
- package/src/lib/viewPrefsHttp.ts +78 -0
- package/src/locales/en.ts +515 -117
- package/src/locales/resolve.ts +17 -8
- package/src/locales/tr.ts +536 -152
- package/src/modals/ConvertModal.vue +62 -11
- package/src/modals/DestinationPickerModal.vue +59 -16
- package/src/modals/Modal.vue +113 -23
- package/src/modals/NewDocumentModal.vue +16 -6
- package/src/modals/NewFolderModal.vue +8 -0
- package/src/modals/PermissionsModal.vue +272 -163
- package/src/modals/PreviewModal.vue +119 -20
- package/src/modals/RenameModal.vue +37 -1
- package/src/styles/base.css +2890 -260
- package/src/styles/sign-fonts.css +118 -0
- package/src/styles/variables.css +60 -0
- package/src/types/Connections.ts +17 -0
- package/src/types/ExplorerConfig.ts +117 -1
- package/src/types/FileNode.ts +50 -0
- package/src/types/Plugins.ts +494 -0
- package/src/types/Public.ts +212 -0
- package/src/viewers/ArchiveViewer.vue +93 -48
- package/src/viewers/CsvViewer.vue +99 -53
- package/src/viewers/DrawioViewer.vue +35 -9
- package/src/viewers/EpubViewer.vue +7 -8
- package/src/viewers/IpynbViewer.vue +3 -1
- package/src/viewers/MermaidViewer.vue +4 -5
- package/src/viewers/PdfViewer.vue +8 -17
- package/src/viewers/PsdViewer.vue +2 -4
- package/src/viewers/TiffViewer.vue +4 -5
- package/src/viewers/Viewer3D.vue +1 -1
- package/LICENSE +0 -21
- package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
- package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
- package/dist/CsvViewer-Bg2_aqzc.js +0 -141
- package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
- package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
- package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
- package/dist/IpynbViewer-D0GwGpnE.js +0 -184
- package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
- package/dist/MermaidViewer-DGwAvRZg.js +0 -137
- package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
- package/dist/PsdViewer-DhGweA0m.js +0 -122
- package/dist/PsdViewer-DhGweA0m.js.map +0 -1
- package/dist/TiffViewer-D0AurvAh.js +0 -142
- package/dist/TiffViewer-D0AurvAh.js.map +0 -1
- package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
- package/dist/index-BArI-B1M.js +0 -26911
- package/dist/index-BArI-B1M.js.map +0 -1
- package/dist/useViewerFetch-czqbd2Lj.js +0 -25
- package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
- package/src/composables/useScrolledX.ts +0 -21
package/src/lib/viewPrefs.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
13
|
+
* the folder's own memory → your default → the instance default
|
|
14
|
+
* → filex's built-in default
|
|
25
15
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* folder
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
*
|
|
55
|
-
* (
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
* ⚠
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
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
|
-
*
|
|
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
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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 =
|
|
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
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
|
148
|
+
return true;
|
|
164
149
|
}
|
|
165
150
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
197
|
-
if (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
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
|
215
|
-
*
|
|
216
|
-
* inside
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
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
|
|
274
|
-
* two keys for one folder
|
|
275
|
-
*
|
|
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
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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]
|
|
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)
|
|
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
|
|
293
|
+
return !!key && !!folderMap.value[key];
|
|
325
294
|
}
|
|
326
295
|
|
|
327
|
-
/** How many folders are remembered —
|
|
328
|
-
*
|
|
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
|
|
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
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
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
|
|
315
|
+
if (!key) return;
|
|
344
316
|
const prev = folderMap.value[key];
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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.
|
|
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
|
|
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;
|
|
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
|
|
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
|
-
*
|
|
385
|
-
* folder
|
|
386
|
-
*
|
|
387
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
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
|
-
* ⚠
|
|
527
|
-
*
|
|
528
|
-
*
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
690
|
+
return defaultColumns.hidden(id);
|
|
674
691
|
}
|
|
675
692
|
|
|
676
693
|
export function setColumnHidden(id: ColumnId, hidden: boolean): void {
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
*
|
|
714
|
-
|
|
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
|
|
725
|
-
*
|
|
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
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
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
|
|
764
|
-
|
|
765
|
-
|
|
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.
|
|
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
|
|
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
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
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
|
-
*
|
|
831
|
-
*
|
|
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
|
|
845
|
-
*
|
|
846
|
-
*
|
|
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
|
-
*
|
|
864
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
950
|
+
fn();
|
|
891
951
|
} catch {
|
|
892
|
-
/*
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
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
|
-
}
|