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