@brftech/filex-core 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -0,0 +1,605 @@
1
+ /**
2
+ * filePreview — gorunum:v1-preview.
3
+ *
4
+ * The first lines of the REAL file, drawn inside the card's preview box when
5
+ * there is nothing better to put there.
6
+ *
7
+ * ## Why this exists at all
8
+ *
9
+ * The backend already answers `thumb_url` for a `.ts` or a `.csv` — but what
10
+ * it renders for them is `internal/thumb/generic.go`: a card tinted from a
11
+ * hash of the extension with the letters "TS" drawn in the middle. Measured
12
+ * on the local backend, `GET /api/files/thumb/6` (app.ts) is a 1,745-byte
13
+ * JPEG of exactly that. So the card was already spending a request to be told
14
+ * the same thing its own footer tile says, in a colour nobody chose.
15
+ *
16
+ * A code file's first five lines say what it is in a way no glyph can. That
17
+ * is the whole idea: for the kinds we can read as text we skip the generic
18
+ * thumbnail entirely and fetch a few kilobytes of the file instead — the same
19
+ * number of requests, a preview that means something.
20
+ *
21
+ * ⚠ This never competes with a REAL thumbnail. The kinds it claims (code,
22
+ * plain text, csv/tsv) are precisely the kinds this backend cannot render a
23
+ * content-derived thumbnail for; images, video, audio and PDFs are untouched
24
+ * and keep their `<img>`.
25
+ *
26
+ * ## What bounds the cost
27
+ *
28
+ * Four independent limits, because any one of them alone is a promise rather
29
+ * than a bound:
30
+ *
31
+ * 1. **Visibility.** Nothing is fetched until the card intersects the
32
+ * viewport (IntersectionObserver, 150px of margin so a scroll does not
33
+ * chase the reader). ⚠ `useThumbs` does NOT do this — it fires on first
34
+ * render for every row the parent hands it — so there was nothing to
35
+ * reuse here; a 400-file folder would have been 400 reads.
36
+ * 2. **A ranged request.** `Range: bytes=0-8191`. The preview endpoint
37
+ * answers 206 through `http.ServeContent` whenever the driver can seek
38
+ * (`vfStream`, manager.go).
39
+ * 3. **A bounded read.** ⚠ A driver that cannot seek answers 200 with the
40
+ * WHOLE body and `Accept-Ranges: none` — the Range header is a request,
41
+ * not a guarantee. So the body is consumed chunk by chunk and the reader
42
+ * is cancelled the moment we have enough. This, not the header, is what
43
+ * actually caps the bytes.
44
+ * 4. **A file-size ceiling + a concurrency cap.** 4 MB and 3 in flight.
45
+ *
46
+ * Results are cached per path AND per version (etag, else mtime, else size),
47
+ * so an edited file re-reads and an unchanged one never does.
48
+ */
49
+
50
+ import { ref } from 'vue';
51
+ import type { FileNode } from '../types/FileNode';
52
+ import { iconFamilyFor } from './fileIcons';
53
+ import { resolveEndpoints } from '../composables/useFileApi';
54
+
55
+ /** What shape the preview takes. `null` = this node has no text preview. */
56
+ export type PreviewKind = 'code' | 'text' | 'table';
57
+
58
+ /**
59
+ * One rendered line. Split in three so the view can print it WITHOUT
60
+ * `v-html` — the content is somebody's file, and interpolation is the only
61
+ * honest way to put it on screen.
62
+ */
63
+ export interface PreviewLine {
64
+ /** Leading whitespace, kept so nesting is visible. */
65
+ indent: string;
66
+ /** The opening token when it is worth tinting ('' = leave it plain). */
67
+ tint: string;
68
+ /** Everything after it, verbatim. */
69
+ text: string;
70
+ }
71
+
72
+ export interface FilePreview {
73
+ kind: PreviewKind;
74
+ /** `code` / `text`. Empty for a table. */
75
+ lines: PreviewLine[];
76
+ /**
77
+ * `table` only: first rows × first columns, already clipped and PADDED to a
78
+ * rectangle. The card lays them out as one CSS grid, so a short row has to
79
+ * carry its empty cells or every row below it shifts a column left.
80
+ */
81
+ rows: string[][];
82
+ /** `table` only: the grid's column count (0 otherwise). */
83
+ cols: number;
84
+ }
85
+
86
+ /* ---------------------------------------------------------------- limits */
87
+
88
+ /** Bytes asked for, and the hard cap on bytes actually read. */
89
+ const MAX_BYTES = 8 * 1024;
90
+ /** Files bigger than this are left alone. See the header: the streaming
91
+ * cancel is the real bound, this is the "never surprise the operator whose
92
+ * driver cannot seek" guard. */
93
+ const MAX_FILE_BYTES = 4 * 1024 * 1024;
94
+ /** Concurrent reads. Three keeps a fast scroll responsive without turning a
95
+ * folder of scripts into a thundering herd against one S3 bucket. */
96
+ const MAX_INFLIGHT = 3;
97
+ /**
98
+ * How much is parsed — NOT how much is shown.
99
+ *
100
+ * ⚠ These used to be 10 lines / 6 rows / 4 columns, and those numbers were
101
+ * chosen for one box: the 184×108 grid card. They are not that box's height
102
+ * in any other view, and a fixed count is the wrong shape of answer anyway.
103
+ * Measured on 2026-09-12, before this change:
104
+ *
105
+ * grid card 184×108 · 10 lines parsed, ~9 fit → close enough
106
+ * gallery tile 220×220 · 10 lines parsed, 15 fit → the bottom
107
+ * HALF of every
108
+ * tile was empty
109
+ * csv, either · 6 rows × 4 cols whatever the box was, and budget.csv has
110
+ * six columns, so two of them were simply never shown
111
+ *
112
+ * The owner's instruction is "as much as we can fit" (2026-09-12), so the box
113
+ * decides: the parser hands over more than any box can show and the box
114
+ * clips what it cannot, which is what `overflow: hidden` and the bottom fade
115
+ * on `.fe-fprev` have always been for. These are therefore CEILINGS on work
116
+ * and DOM, not layout constants — big enough for the largest tile we draw
117
+ * with room over it, small enough that a 400-file folder stays cheap.
118
+ */
119
+ const MAX_LINES = 24;
120
+ /** Characters kept per line. The box clips long lines anyway; this bounds
121
+ * the DOM when a minified file arrives as one 8 KB line. */
122
+ const MAX_LINE_CHARS = 120;
123
+ const MAX_ROWS = 16;
124
+ /** Columns parsed. How many are DRAWN follows the box: the grid lays the
125
+ * cells out on tracks with a readable minimum width and clips the overflow,
126
+ * so a wide tile shows more of the table than a narrow one without either
127
+ * view knowing anything the other does not. */
128
+ const MAX_COLS = 8;
129
+ /** Cache ceiling, mirroring useThumbs' own. Parsed previews are a few hundred
130
+ * bytes each, so this is generous. */
131
+ const MAX_CACHED = 400;
132
+
133
+ /* ------------------------------------------------------------ can we read */
134
+
135
+ /** The two tabular extensions. `sheet` as a FAMILY also holds xls/xlsx/ods,
136
+ * which are zip containers — reading their first bytes gives binary. */
137
+ const TABLE_EXTS = new Set(['csv', 'tsv']);
138
+
139
+ /**
140
+ * Is this row a deleted thing?
141
+ *
142
+ * ⚠ Two fields, because the two ways filex shows you a deleted file disagree.
143
+ * A normal listing marks a soft-deleted row with `trashed`. The TRASH VIEW
144
+ * does not: `FileExplorer.loadTrash` builds its rows from `TrashEntry` by hand
145
+ * and that literal sets neither `trashed` nor `size` — it writes `file_size`
146
+ * and stamps `extra_metadata.deleted_at`.
147
+ *
148
+ * Which means the size ceiling below was already excluding the whole trash
149
+ * view, by accident, because `size` came back undefined. That is not a guard,
150
+ * it is a coincidence one line in an unrelated file would end: the trash row's
151
+ * `path` points at the trash KEY, not at the file it used to be, so a content
152
+ * read there fetches a 404 for every row in the folder.
153
+ */
154
+ function isTrashRow(node: FileNode): boolean {
155
+ if (node.trashed) return true;
156
+ const meta = node.extra_metadata;
157
+ return !!meta && typeof meta === 'object' && 'deleted_at' in meta;
158
+ }
159
+
160
+ /**
161
+ * Which preview this node could have, ignoring whether anything is wired up.
162
+ *
163
+ * Built on `iconFamilyFor` rather than on a second extension list: the
164
+ * families ARE the taxonomy (filex lesson #67), so a new extension registered
165
+ * for the icon set becomes previewable in the same edit. `svg` belongs to the
166
+ * `image` family and is therefore excluded here — it is text, but it has a
167
+ * real thumbnail, and the real one wins.
168
+ */
169
+ export function previewKindFor(node: FileNode): PreviewKind | null {
170
+ if (node.type !== 'file') return null;
171
+ if (node.basename === '.trash' || node.mime_type === 'inode/storage') return null;
172
+ if (isTrashRow(node)) return null;
173
+ // ⚠ A node with NO size is excluded too, not just an empty one: the size is
174
+ // what the 4 MB ceiling is checked against, and "we don't know" is not a
175
+ // basis for deciding to read a file. Every row `projectFileNodes` emits
176
+ // carries one.
177
+ const size = node.size ?? 0;
178
+ if (size <= 0 || size > MAX_FILE_BYTES) return null;
179
+ const ext = (node.extension || '').trim().toLowerCase();
180
+ if (TABLE_EXTS.has(ext)) return 'table';
181
+ const family = iconFamilyFor(node);
182
+ if (family === 'code') return 'code';
183
+ if (family === 'text') return 'text';
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * Is this node's thumbnail a PAGE — the first page of a document, rather than
189
+ * a picture that was already the shape it wanted to be?
190
+ *
191
+ * It matters because of where the card crops. A thumbnail box is landscape
192
+ * (184×108 in the grid) and a page is portrait, so `object-fit: cover` has to
193
+ * throw away most of the height, and WHICH part it throws away is the whole
194
+ * question. Centred — the default, and what this did before — discards the
195
+ * top, which on a document is the letterhead, the title and the date: the
196
+ * only part that says which document it is. Measured on 2026-09-12 against a
197
+ * reference deployment carrying the same defect, five different PDFs sharing
198
+ * one template produced five cards nobody could tell apart, because the only
199
+ * thing that differed between them was the title that had been cropped away.
200
+ *
201
+ * So pages anchor to the top and photographs stay centred, and this is the
202
+ * one place that decides which is which. Built on `iconFamilyFor` rather than
203
+ * a private extension list, for the same reason the preview kinds are: the
204
+ * families ARE the taxonomy (filex lesson #67).
205
+ */
206
+ export function drawsAsPage(node: FileNode): boolean {
207
+ if (node.type !== 'file') return false;
208
+ switch (iconFamilyFor(node)) {
209
+ case 'pdf':
210
+ case 'doc':
211
+ case 'sheet':
212
+ case 'slides':
213
+ return true;
214
+ default:
215
+ return false;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Is this node's thumbnail a still taken from a MOVING picture?
221
+ *
222
+ * A frame lifted out of a video is, on a card, indistinguishable from a
223
+ * photograph — which is the one thing a person needs to know before they
224
+ * click it. The views draw a play badge over these, so the answer lives here
225
+ * rather than in each of them.
226
+ */
227
+ export function drawsAsVideo(node: FileNode): boolean {
228
+ return node.type === 'file' && iconFamilyFor(node) === 'video';
229
+ }
230
+
231
+ /* --------------------------------------------------------------- parsing */
232
+
233
+ /**
234
+ * Openers worth a tint. Deliberately small and deliberately language-agnostic:
235
+ * the brief's own instruction is that plain text beats fake colour, so a token
236
+ * is tinted only when it really is a keyword. A JSON file opening on `{`, or a
237
+ * YAML file opening on a key, gets no colour at all — which is the correct
238
+ * answer, not a missing feature.
239
+ */
240
+ const KEYWORDS = new Set([
241
+ 'import', 'export', 'from', 'require', 'include', 'use', 'using', 'package',
242
+ 'module', 'namespace', 'const', 'let', 'var', 'function', 'func', 'fn', 'def',
243
+ 'class', 'struct', 'enum', 'interface', 'type', 'impl', 'trait',
244
+ 'public', 'private', 'protected', 'static', 'async', 'await', 'return',
245
+ 'if', 'else', 'for', 'while', 'switch', 'case', 'try', 'catch',
246
+ 'select', 'insert', 'update', 'delete', 'create', 'alter', 'drop',
247
+ ]);
248
+
249
+ /** Markdown-ish line markers — the only thing tinted in a `text` preview. */
250
+ const TEXT_MARKER = /^(#{1,6}|[-*+]|>|\d+\.)$/;
251
+
252
+ /** Strip the punctuation a keyword is usually wearing (`const,` `if(` `def:`). */
253
+ function bareToken(token: string): string {
254
+ return token.replace(/[(){}[\];:,.]+$/, '').toLowerCase();
255
+ }
256
+
257
+ function toLine(raw: string, kind: PreviewKind): PreviewLine {
258
+ const clipped = raw.length > MAX_LINE_CHARS ? raw.slice(0, MAX_LINE_CHARS) : raw;
259
+ const m = /^(\s*)(\S+)([\s\S]*)$/.exec(clipped);
260
+ if (!m) return { indent: '', tint: '', text: clipped };
261
+ const [, indent, first, rest] = m;
262
+ const tintable =
263
+ kind === 'code' ? KEYWORDS.has(bareToken(first)) : TEXT_MARKER.test(first);
264
+ if (!tintable) return { indent: '', tint: '', text: clipped };
265
+ return { indent, tint: first, text: rest };
266
+ }
267
+
268
+ /**
269
+ * Split one delimited row into cells, honouring `"…"` quoting (and `""` as an
270
+ * escaped quote) so a value containing the separator does not become two
271
+ * columns. Small on purpose — this draws six rows on a 184px card, it is not
272
+ * a CSV parser and must never grow into one.
273
+ */
274
+ export function splitCells(line: string, sep: string): string[] {
275
+ const out: string[] = [];
276
+ let cur = '';
277
+ let quoted = false;
278
+ for (let i = 0; i < line.length; i++) {
279
+ const c = line[i];
280
+ if (quoted) {
281
+ if (c === '"') {
282
+ if (line[i + 1] === '"') {
283
+ cur += '"';
284
+ i++;
285
+ } else {
286
+ quoted = false;
287
+ }
288
+ } else {
289
+ cur += c;
290
+ }
291
+ } else if (c === '"') {
292
+ quoted = true;
293
+ } else if (c === sep) {
294
+ out.push(cur);
295
+ cur = '';
296
+ } else {
297
+ cur += c;
298
+ }
299
+ }
300
+ out.push(cur);
301
+ return out;
302
+ }
303
+
304
+ /** `,` normally; `\t` for a .tsv; `;` when the first row clearly uses it —
305
+ * the shape Excel writes in most of Europe, Turkey included. */
306
+ function separatorFor(ext: string, firstLine: string): string {
307
+ if (ext === 'tsv') return '\t';
308
+ if (!firstLine.includes(',') && firstLine.includes(';')) return ';';
309
+ return ',';
310
+ }
311
+
312
+ /**
313
+ * Bytes → a drawable preview, or null when the bytes are not text after all
314
+ * (a `.json` that is really a binary blob, ciphertext, a mislabelled file).
315
+ *
316
+ * `truncated` says the byte cap cut the stream, in which case the last line is
317
+ * dropped: half a line of source looks like a bug, not like a preview.
318
+ */
319
+ export function parsePreview(
320
+ bytes: Uint8Array,
321
+ kind: PreviewKind,
322
+ ext: string,
323
+ truncated: boolean,
324
+ ): FilePreview | null {
325
+ // A NUL in the first few KB is the cheapest reliable "this is not text".
326
+ for (let i = 0; i < bytes.length; i++) {
327
+ if (bytes[i] === 0) return null;
328
+ }
329
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
330
+ // A decode full of replacement characters is binary wearing a text
331
+ // extension — or a file in an encoding we are not going to guess at.
332
+ let bad = 0;
333
+ for (const ch of text) if (ch === '�') bad++;
334
+ if (text.length > 0 && bad / text.length > 0.1) return null;
335
+
336
+ const lines = text.split(/\r\n|\r|\n/);
337
+ if (truncated && lines.length > 1) lines.pop();
338
+ // Blank lines at either end waste a box that only holds eight — and every
339
+ // text file that ends in a newline has one. Interior blanks stay: they are
340
+ // part of how the file looks, which is the whole point.
341
+ while (lines.length && lines[0].trim() === '') lines.shift();
342
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
343
+ if (lines.length === 0) return null;
344
+
345
+ if (kind === 'table') {
346
+ const sep = separatorFor(ext, lines[0]);
347
+ const rows = lines
348
+ .filter((l) => l.trim() !== '')
349
+ .slice(0, MAX_ROWS)
350
+ .map((l) => splitCells(l, sep).slice(0, MAX_COLS).map((c) => c.trim().slice(0, 24)));
351
+ if (rows.length === 0) return null;
352
+ const cols = rows.reduce((n, r) => Math.max(n, r.length), 0);
353
+ for (const r of rows) while (r.length < cols) r.push('');
354
+ return { kind, lines: [], rows, cols };
355
+ }
356
+
357
+ return {
358
+ kind,
359
+ lines: lines.slice(0, MAX_LINES).map((l) => toLine(l, kind)),
360
+ rows: [],
361
+ cols: 0,
362
+ };
363
+ }
364
+
365
+ /* ---------------------------------------------------------------- loading */
366
+
367
+ export interface PreviewLoaderOptions {
368
+ /** Same prefix the explorer hands the views. `undefined` = no API wired
369
+ * (a legacy embedder on an explicit `endpoint`) → the loader stays off. */
370
+ apiBase?: string;
371
+ /**
372
+ * ⚠ ASYNC, and it is the whole reason this signature is a function and not
373
+ * an object. Spreading a Promise into a headers literal yields `{}` — the
374
+ * request goes out with no Authorization and 401s with nothing in the
375
+ * console. Every call site here `await`s it.
376
+ */
377
+ authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
378
+ credentials?: RequestCredentials;
379
+ /**
380
+ * Answered on every decision, not once at construction: "is reading file
381
+ * bytes off right now?". The explorer passes its encrypted-folder state —
382
+ * inside one, every body on the wire is ciphertext, and fetching 8 KB of it
383
+ * to discover that it does not decode is a request spent to learn nothing.
384
+ */
385
+ disabled?: () => boolean;
386
+ }
387
+
388
+ export interface FilePreviewLoader {
389
+ /** False when nothing is wired — callers must then keep their old markup. */
390
+ readonly enabled: boolean;
391
+ /** The kind this node would get, or null (including when disabled). */
392
+ kindFor: (node: FileNode) => PreviewKind | null;
393
+ /** Reactive read. null = not loaded (yet), or never. */
394
+ get: (node: FileNode) => FilePreview | null;
395
+ /** Template-ref sink: hand it the preview box and its node. */
396
+ bind: (el: Element | null, node: FileNode) => void;
397
+ /** In-flight + queued, for tests and measurement. */
398
+ stats: () => { loaded: number; inflight: number; queued: number; failed: number };
399
+ dispose: () => void;
400
+ }
401
+
402
+ /** path + version: an edited file re-reads, an untouched one never does. */
403
+ function keyOf(node: FileNode): string {
404
+ const etag = typeof node.etag === 'string' ? node.etag : '';
405
+ const version = etag || String(node.last_modified ?? '') || String(node.size ?? '');
406
+ return `${node.path}${version}`;
407
+ }
408
+
409
+ export function createFilePreviews(opts: PreviewLoaderOptions): FilePreviewLoader {
410
+ const enabled =
411
+ opts.apiBase !== undefined &&
412
+ typeof opts.authHeaders === 'function' &&
413
+ typeof IntersectionObserver !== 'undefined' &&
414
+ typeof fetch === 'function';
415
+
416
+ const cache = ref<Record<string, FilePreview>>({});
417
+ const order: string[] = [];
418
+ const failed = new Set<string>();
419
+ const queued = new Set<string>();
420
+ const queue: FileNode[] = [];
421
+ const controllers = new Set<AbortController>();
422
+ /** element → the key it is currently observed for, so a re-render of the
423
+ * same card does not re-observe and a recycled row does. */
424
+ const bound = new WeakMap<Element, string>();
425
+ let inflight = 0;
426
+ let observer: IntersectionObserver | null = null;
427
+ /** The observed element's node, kept until the callback fires. */
428
+ const watching = new Map<Element, FileNode>();
429
+
430
+ function previewUrl(path: string): string {
431
+ // resolveEndpoints is the one place that knows how apiBase becomes a
432
+ // manager URL; `?q=` + `&action=` is the pair every other caller sends.
433
+ const manager = resolveEndpoints({ apiBase: opts.apiBase }).manager;
434
+ const sep = manager.includes('?') ? '&' : '?';
435
+ const query = new URLSearchParams({ q: 'preview', action: 'preview', path });
436
+ return `${manager}${sep}${query.toString()}`;
437
+ }
438
+
439
+ function remember(key: string, value: FilePreview) {
440
+ if (order.length >= MAX_CACHED) {
441
+ const evict = order.shift();
442
+ if (evict) {
443
+ const next = { ...cache.value };
444
+ delete next[evict];
445
+ cache.value = next;
446
+ }
447
+ }
448
+ order.push(key);
449
+ cache.value = { ...cache.value, [key]: value };
450
+ }
451
+
452
+ /**
453
+ * Read at most `MAX_BYTES` of the body, whatever the server decided to send.
454
+ * A 206 is already short; a 200 from a driver that cannot seek is the whole
455
+ * file, and cancelling the reader is what stops it.
456
+ */
457
+ async function readHead(res: Response): Promise<{ bytes: Uint8Array; truncated: boolean }> {
458
+ const body = res.body;
459
+ if (!body) {
460
+ const buf = new Uint8Array(await res.arrayBuffer());
461
+ return { bytes: buf.slice(0, MAX_BYTES), truncated: buf.length > MAX_BYTES };
462
+ }
463
+ const reader = body.getReader();
464
+ const chunks: Uint8Array[] = [];
465
+ let total = 0;
466
+ try {
467
+ while (total < MAX_BYTES) {
468
+ const { done, value } = await reader.read();
469
+ if (done) break;
470
+ if (value && value.length) {
471
+ chunks.push(value);
472
+ total += value.length;
473
+ }
474
+ }
475
+ } finally {
476
+ try {
477
+ await reader.cancel();
478
+ } catch {
479
+ /* already closed */
480
+ }
481
+ }
482
+ const joined = new Uint8Array(Math.min(total, MAX_BYTES));
483
+ let at = 0;
484
+ for (const c of chunks) {
485
+ if (at >= joined.length) break;
486
+ joined.set(c.subarray(0, joined.length - at), at);
487
+ at += c.length;
488
+ }
489
+ return { bytes: joined, truncated: total >= MAX_BYTES };
490
+ }
491
+
492
+ async function load(node: FileNode, kind: PreviewKind): Promise<void> {
493
+ const key = keyOf(node);
494
+ const ctrl = new AbortController();
495
+ controllers.add(ctrl);
496
+ try {
497
+ // ⚠ The await. Without it this object holds a Promise under
498
+ // `authHeaders`, fetch drops it, and the request 401s silently.
499
+ const base = opts.authHeaders ? await opts.authHeaders() : {};
500
+ const res = await fetch(previewUrl(node.path), {
501
+ headers: { ...base, Accept: '*/*', Range: `bytes=0-${MAX_BYTES - 1}` },
502
+ credentials: opts.credentials,
503
+ signal: ctrl.signal,
504
+ });
505
+ if (!res.ok && res.status !== 206) throw new Error(String(res.status));
506
+ const { bytes, truncated } = await readHead(res);
507
+ ctrl.abort();
508
+ const ext = (node.extension || '').trim().toLowerCase();
509
+ const parsed = parsePreview(bytes, kind, ext, truncated);
510
+ if (!parsed) {
511
+ failed.add(key);
512
+ return;
513
+ }
514
+ remember(key, parsed);
515
+ } catch {
516
+ // 403/404/offline/not-really-text — the card keeps its type tile, and
517
+ // we do not retry inside this session (same policy as useThumbs).
518
+ failed.add(key);
519
+ } finally {
520
+ controllers.delete(ctrl);
521
+ }
522
+ }
523
+
524
+ function pump() {
525
+ while (inflight < MAX_INFLIGHT && queue.length > 0) {
526
+ const node = queue.shift()!;
527
+ const kind = previewKindFor(node);
528
+ queued.delete(keyOf(node));
529
+ if (!kind) continue;
530
+ inflight++;
531
+ void load(node, kind).finally(() => {
532
+ inflight--;
533
+ pump();
534
+ });
535
+ }
536
+ }
537
+
538
+ function enqueue(node: FileNode) {
539
+ const key = keyOf(node);
540
+ if (cache.value[key] || failed.has(key) || queued.has(key)) return;
541
+ queued.add(key);
542
+ queue.push(node);
543
+ pump();
544
+ }
545
+
546
+ function ensureObserver(): IntersectionObserver | null {
547
+ if (!enabled) return null;
548
+ if (!observer) {
549
+ observer = new IntersectionObserver(
550
+ (entries) => {
551
+ for (const entry of entries) {
552
+ if (!entry.isIntersecting) continue;
553
+ const node = watching.get(entry.target);
554
+ observer?.unobserve(entry.target);
555
+ watching.delete(entry.target);
556
+ if (node) enqueue(node);
557
+ }
558
+ },
559
+ { rootMargin: '150px' },
560
+ );
561
+ }
562
+ return observer;
563
+ }
564
+
565
+ function kindFor(node: FileNode): PreviewKind | null {
566
+ if (!enabled) return null;
567
+ if (opts.disabled?.()) return null;
568
+ return previewKindFor(node);
569
+ }
570
+
571
+ return {
572
+ enabled,
573
+ kindFor,
574
+ get(node: FileNode): FilePreview | null {
575
+ if (!enabled) return null;
576
+ return cache.value[keyOf(node)] ?? null;
577
+ },
578
+ bind(el: Element | null, node: FileNode) {
579
+ if (!el || !kindFor(node)) return;
580
+ const key = keyOf(node);
581
+ if (bound.get(el) === key) return;
582
+ bound.set(el, key);
583
+ if (cache.value[key] || failed.has(key)) return;
584
+ const io = ensureObserver();
585
+ if (!io) return;
586
+ watching.set(el, node);
587
+ io.observe(el);
588
+ },
589
+ stats: () => ({
590
+ loaded: order.length,
591
+ inflight,
592
+ queued: queue.length,
593
+ failed: failed.size,
594
+ }),
595
+ dispose() {
596
+ observer?.disconnect();
597
+ observer = null;
598
+ watching.clear();
599
+ for (const c of controllers) c.abort();
600
+ controllers.clear();
601
+ queue.length = 0;
602
+ queued.clear();
603
+ },
604
+ };
605
+ }
@@ -17,6 +17,27 @@ export function stripAdapter(p: string): string {
17
17
  return idx === -1 ? p : p.slice(idx + 3);
18
18
  }
19
19
 
20
+ /**
21
+ * The folder a row sits in, without its storage: `My files://Photos/a.jpg` →
22
+ * `Photos`, and `''` for a row at a storage's root.
23
+ *
24
+ * ⚠⚠ Split on the FIRST `://` (`stripAdapter`), never with a URL-scheme
25
+ * pattern. The list, the grid and the gallery each carried their own copy that
26
+ * stripped `[a-z][a-z0-9+.-]*` followed by `://` — the RFC 3986 shape of a SCHEME, which a
27
+ * storage name is not. A name with a space in it (`My files`, the one the
28
+ * screenshot fixtures use, and a perfectly ordinary thing to call a drive)
29
+ * does not match it, so nothing was stripped, the "folder" became
30
+ * `My files://Photos`, and the Location column printed
31
+ * `My files/My files://Photos` — the storage twice and the scheme on show.
32
+ * Measured on Starred, 2026-09-14. An underscore or a leading digit broke it
33
+ * the same way.
34
+ */
35
+ export function parentDirOf(path: string): string {
36
+ const rel = stripAdapter(String(path ?? ''));
37
+ const idx = rel.lastIndexOf('/');
38
+ return idx === -1 ? '' : rel.slice(0, idx);
39
+ }
40
+
20
41
  /**
21
42
  * Hide system/internal entries the user must never see as files:
22
43
  * thumbnails, version history, the soft-delete store, keepdir markers,
@@ -220,6 +241,11 @@ export async function hydrateTrashRow(
220
241
  * 2026-09-04). A second copy of a mapping is a second chance to forget it.
221
242
  */
222
243
  export const VIRTUAL_SEGMENTS: Record<string, string> = {
244
+ /* gorunum:v3-shell — Home is a destination like the rest, so it gets a
245
+ sentinel like the rest: a restored tab, a reload and a pasted `#.home`
246
+ all have to reopen the overview instead of asking the backend for a
247
+ folder called `.home` and landing on "not found". */
248
+ '.home': 'node.home',
223
249
  '.trash': 'node.trash',
224
250
  '.recent': 'node.recent',
225
251
  '.starred': 'node.starred',
@@ -285,3 +311,49 @@ export function virtualSegmentLabel(segment: string, t: (key: string) => string)
285
311
  const tag = tagOfSegment(segment);
286
312
  return tag ? `#${tag}` : '';
287
313
  }
314
+
315
+ /**
316
+ * True when a USER PATH is one of the virtual views rather than a folder that
317
+ * lives in a storage — `.trash`, `.recent`, `.starred`, `.shared`, `.home`,
318
+ * `.tag~<name>`.
319
+ *
320
+ * ⚠⚠ Why a predicate and not just "call `virtualSegmentLabel` and see". The
321
+ * caller that needs this is deciding whether to QUALIFY the path, and
322
+ * qualifying a sentinel destroys it: in multi-storage mode `qualify('.starred')`
323
+ * splits the first segment off as the adapter and answers `.starred://`, so
324
+ * from there on the sentinel is a STORAGE NAME. A storage name is not a path
325
+ * segment, `virtualSegmentLabel` is never asked about it, and the breadcrumb
326
+ * prints `.starred` — which is exactly what the owner saw on 2026-09-13, in
327
+ * every virtual view at once, the night the pane was extracted into
328
+ * `FilePane.vue` and each pane began deriving its own crumb inputs. The
329
+ * resolver was being called the whole time, with an argument that could not
330
+ * match.
331
+ *
332
+ * ⚠ One definition, here beside the map and the tag prefix it asks about, so
333
+ * "is this a view or a folder?" cannot be answered two ways: `FileExplorer`'s
334
+ * `virtualViewOf()` (which also names the KIND) is built on this one.
335
+ */
336
+ export function isVirtualViewPath(path: string): boolean {
337
+ const clean = String(path ?? '').replace(/^\/+|\/+$/g, '');
338
+ if (!clean) return false;
339
+ return !!VIRTUAL_SEGMENTS[clean] || !!tagOfSegment(clean);
340
+ }
341
+
342
+ /**
343
+ * gorunum:v1 — folders before files, whatever the sort column is.
344
+ *
345
+ * Every file manager a person has used puts directories first, and the reason
346
+ * is not taste: a directory is a place and a file is a thing, and a listing
347
+ * that interleaves them makes the reader check the icon of every row to find
348
+ * out where they can go next. Sorting by size or date interleaves them worst
349
+ * of all, because a folder has neither.
350
+ *
351
+ * Applied as the PRIMARY comparator by both views, so it cannot mean one
352
+ * thing in the list and another in the grid — the two sorts are separate code
353
+ * and this is the rule they have to share (filex lesson #67).
354
+ */
355
+ export function byFoldersFirst(a: { type?: string }, b: { type?: string }): number {
356
+ const ad = a.type === 'dir' ? 0 : 1;
357
+ const bd = b.type === 'dir' ? 0 : 1;
358
+ return ad - bd;
359
+ }