@brftech/filex-core 0.40.0 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-DxxjhYZD.js +124 -0
  3. package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
  4. package/dist/CsvViewer-CyuwENI_.js +141 -0
  5. package/dist/CsvViewer-CyuwENI_.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
  7. package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
  8. package/dist/EpubViewer-K9PjCMmS.js +163 -0
  9. package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
  10. package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
  11. package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
  12. package/dist/MermaidViewer-Bp73aId8.js +137 -0
  13. package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
  14. package/dist/PsdViewer-C2eLivw1.js +122 -0
  15. package/dist/PsdViewer-C2eLivw1.js.map +1 -0
  16. package/dist/TiffViewer-DsHg5gcz.js +142 -0
  17. package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
  18. package/dist/Viewer3D-C_dc2HN9.js +75 -0
  19. package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
  20. package/dist/filex-core.js +218 -20361
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-vxMZpYb-.js +26700 -0
  25. package/dist/index-vxMZpYb-.js.map +1 -0
  26. package/dist/index.d.ts +4212 -862
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2154 -506
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +39 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +951 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +172 -56
  39. package/src/components/GridView.vue +331 -82
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1173 -194
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +26 -1
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +6 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +135 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +35 -14
  60. package/src/components/Toolbar.vue +953 -465
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +89 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useLocale.ts +382 -26
  66. package/src/composables/useRowTouch.ts +95 -0
  67. package/src/index.ts +177 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +31 -2
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +151 -116
  80. package/src/lib/timezone.ts +501 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +416 -26
  84. package/src/locales/tr.ts +409 -26
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +557 -476
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5409 -8
  93. package/src/styles/variables.css +139 -65
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -6,8 +6,18 @@
6
6
  * each family gets an accent via `--fe-icon-<family>` (variables.css),
7
7
  * applied through the `fe-ficon--<family>` class in base.css.
8
8
  *
9
- * Scope: file-TYPE icons only. Action emojis (trash/star/…) and the
10
- * special `.trash` / storage rows stay emoji see the views.
9
+ * Scope: what a listing row IS its file type, plus the one STATE that
10
+ * changes the picture rather than the caption (an encrypted folder, bottom of
11
+ * this file). What a row DOES is `lib/actionIcons.ts`.
12
+ *
13
+ * ⚠ `.trash` and STORAGE rows used to be the documented exception here, and
14
+ * they were drawn as 💾 and 🗑 by three separate copies of the same line in
15
+ * GridView, GalleryView and ListView. On the multi-storage "My files" root —
16
+ * the screen the sidebar now lands everybody on — that made a pixelated
17
+ * floppy disk the first thing on the page, in a shape that changes between
18
+ * Windows, macOS and Linux because it is a system font's idea of the glyph
19
+ * rather than ours. They are families like any other now, so the card, the
20
+ * tile and the row all get one definition (filex lesson #67).
11
21
  */
12
22
 
13
23
  export type IconFamily =
@@ -22,6 +32,11 @@ export type IconFamily =
22
32
  | 'archive'
23
33
  | 'code'
24
34
  | 'text'
35
+ // Not file types: the two rows a listing can hold that are PLACES rather
36
+ // than things. They carry no extension, so `iconFamilyFor` recognises them
37
+ // from the node instead.
38
+ | 'storage'
39
+ | 'trash'
25
40
  | 'unknown';
26
41
 
27
42
  const EXT_FAMILIES: Record<string, IconFamily> = {};
@@ -35,7 +50,11 @@ reg('video', ['mp4', 'webm', 'mov', 'mkv', 'avi', 'ogv', 'm4v']);
35
50
  reg('audio', ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus']);
36
51
  reg('pdf', ['pdf']);
37
52
  reg('doc', ['doc', 'docx', 'odt', 'rtf']);
38
- reg('sheet', ['xls', 'xlsx', 'ods', 'csv']);
53
+ /* gorunum:v1-preview `tsv` joins its twin. It was the one tabular extension
54
+ * with no family at all (so: the "unknown" glyph, and invisible to the
55
+ * Spreadsheet filter), while the text viewer has read it as a table since
56
+ * v0.5 (`viewers/CsvViewer.vue` branches on `ext === 'tsv'`). */
57
+ reg('sheet', ['xls', 'xlsx', 'ods', 'csv', 'tsv']);
39
58
  reg('slides', ['ppt', 'pptx', 'odp']);
40
59
  reg('archive', ['zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'xz', 'zst']);
41
60
  reg('code', [
@@ -45,11 +64,31 @@ reg('code', [
45
64
  ]);
46
65
  reg('text', ['txt', 'md', 'markdown', 'log', 'ini', 'conf', 'cfg', 'env']);
47
66
 
67
+ /**
68
+ * The synthetic "a storage" row a listing can carry.
69
+ *
70
+ * The backend never sends `inode/storage`; FileExplorer mints these rows for
71
+ * the multi-storage overview, and they are `type: 'dir'` with no folder behind
72
+ * them — every mutation would 4xx. The test was written out longhand in three
73
+ * view components, which is how a fourth surface came to be written without
74
+ * it, so it is a function now and this file is its one home.
75
+ */
76
+ export function isStorageRow(node: { mime_type?: string | null }): boolean {
77
+ return node.mime_type === 'inode/storage';
78
+ }
79
+
48
80
  /** Pick the icon family for a listing node. */
49
81
  export function iconFamilyFor(node: {
50
82
  type?: string;
51
83
  extension?: string | null;
84
+ mime_type?: string | null;
85
+ basename?: string | null;
52
86
  }): IconFamily {
87
+ // ⚠ Both of these are also `type: 'dir'`, so they have to be answered
88
+ // BEFORE the folder branch or they fall through to a plain folder — which
89
+ // is what `HomeView` still does by asking for `{ type: 'dir' }` by hand.
90
+ if (isStorageRow(node)) return 'storage';
91
+ if (node.basename === '.trash') return 'trash';
53
92
  if (node.type === 'dir') return 'folder';
54
93
  const ext = (node.extension || '').toLowerCase();
55
94
  return EXT_FAMILIES[ext] ?? 'unknown';
@@ -64,8 +103,15 @@ const FILE_BASE =
64
103
  const GLYPHS: Record<IconFamily, string> = {
65
104
  // Folder is the one "filled" member — a soft currentColor wash keeps it
66
105
  // visually anchored without breaking the line style.
106
+ /**
107
+ * gorunum:v1 — the folder is SOLID, and it is the one glyph that never
108
+ * gets a tile. A folder is the most common thing in a listing; drawn as an
109
+ * outline in the same weight as the file glyphs it disappears into them,
110
+ * and the eye has to read the label to tell a folder from a document. Filled
111
+ * and in its own colour it is recognisable before the name is read.
112
+ */
67
113
  folder:
68
- '<path d="M3 7a2 2 0 0 1 2-2h4.2l2 2.4H19a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="currentColor" fill-opacity="0.14"/>',
114
+ '<path d="M2.5 6.5A2.5 2.5 0 0 1 5 4h4.6a2.5 2.5 0 0 1 1.77.73L13 6.4h6a2.5 2.5 0 0 1 2.5 2.5v8.6A2.5 2.5 0 0 1 19 20H5a2.5 2.5 0 0 1-2.5-2.5z" fill="currentColor" stroke="none"/>',
69
115
  image:
70
116
  '<rect x="3" y="5" width="18" height="14" rx="2"/>' +
71
117
  '<circle cx="8.6" cy="10" r="1.6"/>' +
@@ -99,6 +145,17 @@ const GLYPHS: Record<IconFamily, string> = {
99
145
  text:
100
146
  FILE_BASE +
101
147
  '<path d="M8.5 12h7M8.5 15h7M8.5 18h4.5"/>',
148
+ // The two-bay drive the sidebar already draws for the same storages, so a
149
+ // drive is one shape across the panel rather than one per surface.
150
+ storage:
151
+ '<rect x="3" y="5" width="18" height="6" rx="1.6"/>' +
152
+ '<rect x="3" y="13" width="18" height="6" rx="1.6"/>' +
153
+ '<path d="M6.5 8h.01M6.5 16h.01"/>',
154
+ trash:
155
+ '<path d="M4 7h16"/>' +
156
+ '<path d="M9.5 7V5.6A1.6 1.6 0 0 1 11.1 4h1.8a1.6 1.6 0 0 1 1.6 1.6V7"/>' +
157
+ '<path d="M6.2 7l.85 11.6A1.6 1.6 0 0 0 8.65 20h6.7a1.6 1.6 0 0 0 1.6-1.4L17.8 7"/>' +
158
+ '<path d="M10.2 10.8v5.6M13.8 10.8v5.6"/>',
102
159
  unknown:
103
160
  FILE_BASE +
104
161
  '<path d="M10.3 13.1a1.8 1.8 0 1 1 2.5 1.9c-.6.3-.8.7-.8 1.4"/>' +
@@ -122,6 +179,243 @@ export function iconSvg(family: IconFamily): string {
122
179
  }
123
180
 
124
181
  /** Convenience: node → SVG markup in one call. */
125
- export function fileIconSvg(node: { type?: string; extension?: string | null }): string {
182
+ export function fileIconSvg(node: Parameters<typeof iconFamilyFor>[0]): string {
126
183
  return iconSvg(iconFamilyFor(node));
127
184
  }
185
+
186
+ const TILE_CACHE = new Map<IconFamily, string>();
187
+
188
+ /**
189
+ * gorunum:v1 — the listing badge: a filled, rounded square in the family's
190
+ * colour with the glyph knocked out in white.
191
+ *
192
+ * Why not the bare coloured glyph it replaces: at 16px a line drawing in a
193
+ * pale accent is a smudge, and twelve of them down a column read as noise
194
+ * rather than as twelve kinds of file. A solid chip of colour is legible at a
195
+ * glance, holds its shape at any density, and gives the row a consistent left
196
+ * edge whether the file has a thumbnail or not.
197
+ *
198
+ * The folder is the exception and returns its solid glyph untiled — a folder
199
+ * is not a file type, and boxing it makes a listing look like a colour chart.
200
+ */
201
+ export function iconTile(family: IconFamily): string {
202
+ if (family === 'folder') {
203
+ return `<span class="fe-ftile fe-ftile--folder">${iconSvg(family)}</span>`;
204
+ }
205
+ let tile = TILE_CACHE.get(family);
206
+ if (!tile) {
207
+ tile = `<span class="fe-ftile fe-ftile--${family}">${iconSvg(family)}</span>`;
208
+ TILE_CACHE.set(family, tile);
209
+ }
210
+ return tile;
211
+ }
212
+
213
+ /** Convenience: node → tile markup in one call. */
214
+ export function fileIconTile(node: Parameters<typeof iconFamilyFor>[0]): string {
215
+ return iconTile(iconFamilyFor(node));
216
+ }
217
+
218
+ /* ======================================================================
219
+ * ikon:emoji — the one listing marker that is a STATE, not a type.
220
+ *
221
+ * An end-to-end encrypted folder (wiring:e2) is a folder like any other; what
222
+ * is special about it is a fact, not a kind, so it cannot be an `IconFamily`
223
+ * — `iconFamilyFor` is the taxonomy `lib/fileFilters` filters by, and moving
224
+ * these rows out of `folder` would quietly drop them from the Folders filter.
225
+ * It lives beside the families instead, as one definition.
226
+ *
227
+ * ⚠ It was three. `specialEmojiFor(n) { if (n.type === 'dir' && n.e2e) return
228
+ * '🔒' }` was copy-pasted verbatim into ListView, GridView and GalleryView —
229
+ * the exact shape of the thing the duplicate-code gate exists to stop, and
230
+ * the second time this file has had to absorb it (the first was `.trash` and
231
+ * the storage row, drawn as 🗑 and 💾 by the same three copies).
232
+ * ====================================================================== */
233
+
234
+ /** True for a folder that is end-to-end encrypted. */
235
+ export function isEncryptedFolder(node: { type?: string; e2e?: boolean }): boolean {
236
+ return node.type === 'dir' && node.e2e === true;
237
+ }
238
+
239
+ /**
240
+ * The solid folder with a padlock cut OUT of it.
241
+ *
242
+ * One path, `fill-rule="evenodd"`: the shackle and the body are subpaths
243
+ * inside the folder outline, so they become holes and show whatever the tile
244
+ * sits on. That is deliberate — the alternative (drawing the lock in a
245
+ * background colour) picks one background and is wrong on the other two
246
+ * surfaces this tile appears on.
247
+ *
248
+ * ⚠ It replaces a 🔒 that replaced the folder entirely, so the row lost the
249
+ * one thing it had always said: that it is a folder. Both facts are here now,
250
+ * and the callers give the span an `aria-label` for the half a shape cannot
251
+ * carry.
252
+ */
253
+ const LOCKED_FOLDER =
254
+ '<path fill="currentColor" stroke="none" fill-rule="evenodd" d="' +
255
+ /* the folder — the same outline as GLYPHS.folder */
256
+ 'M2.5 6.5A2.5 2.5 0 0 1 5 4h4.6a2.5 2.5 0 0 1 1.77.73L13 6.4h6a2.5 2.5 0 0 1 2.5 2.5v8.6A2.5 2.5 0 0 1 19 20H5a2.5 2.5 0 0 1-2.5-2.5z' +
257
+ /* the shackle: outer arc over, step in, inner arc back, close down the leg */
258
+ 'M9.7 12a2.3 2.3 0 0 1 4.6 0h-1.05a1.25 1.25 0 0 0-2.5 0z' +
259
+ /* the body */
260
+ 'M9.2 12h5.6a1 1 0 0 1 1 1v3.6a1 1 0 0 1-1 1H9.2a1 1 0 0 1-1-1V13a1 1 0 0 1 1-1z' +
261
+ '"/>';
262
+
263
+ let LOCKED_FOLDER_TILE = '';
264
+
265
+ /**
266
+ * Tile markup for an encrypted folder — `fe-ftile--folder`'s own box, so it
267
+ * is the same size, colour and (lack of) chip as every other folder in the
268
+ * column; only the shape differs.
269
+ */
270
+ export function encryptedFolderTile(): string {
271
+ if (!LOCKED_FOLDER_TILE) {
272
+ LOCKED_FOLDER_TILE =
273
+ '<span class="fe-ftile fe-ftile--folder fe-ftile--folder-locked">' +
274
+ '<svg class="fe-ficon fe-ficon--folder" viewBox="0 0 24 24" fill="none" ' +
275
+ 'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" ' +
276
+ 'stroke-linejoin="round" aria-hidden="true" focusable="false">' +
277
+ `${LOCKED_FOLDER}</svg></span>`;
278
+ }
279
+ return LOCKED_FOLDER_TILE;
280
+ }
281
+
282
+ /* ======================================================================
283
+ * gorunum:v1-preview — what the thing IS, said in words.
284
+ *
285
+ * The Type column used to print the raw extension in caps: `TS`, `CSV`,
286
+ * `JPG`. That is not a kind, it is a suffix — it answers "what did the file
287
+ * get called" when the question the column asks is "what is this". The
288
+ * reference shell prints `TypeScript`, `Spreadsheet`, `Image`.
289
+ *
290
+ * ⚠ ONE table, here, beside the extension → family map it is built on top of
291
+ * (filex lesson #67). Every surface that names a kind — the Type column
292
+ * today, an info panel tomorrow — calls `typeLabelFor` and gets the same
293
+ * answer; a second table beside it would disagree the first week somebody
294
+ * added an extension to only one of them.
295
+ *
296
+ * Three tiers, in order:
297
+ * 1. the extension has a name of its own → `ftype.typescript`
298
+ * 2. otherwise its FAMILY has one → `ftype.code`, `ftype.image`
299
+ * 3. neither (family `unknown`) → the uppercased extension
300
+ *
301
+ * Tier 3 is the deliberate fallback the brief asks for: an unmapped `.zig`
302
+ * reads "ZIG", which is at least the truth, rather than an empty cell or a
303
+ * confident lie like "File".
304
+ * ====================================================================== */
305
+
306
+ /** Extension → its own i18n key. Only extensions whose NAME differs from
307
+ * what the family would say need an entry here. */
308
+ const EXT_TYPE_KEYS: Record<string, string> = {
309
+ // Languages the person reading the column thinks of by name.
310
+ ts: 'ftype.typescript',
311
+ tsx: 'ftype.typescript',
312
+ mts: 'ftype.typescript',
313
+ cts: 'ftype.typescript',
314
+ js: 'ftype.javascript',
315
+ jsx: 'ftype.javascript',
316
+ mjs: 'ftype.javascript',
317
+ cjs: 'ftype.javascript',
318
+ vue: 'ftype.vue',
319
+ py: 'ftype.python',
320
+ go: 'ftype.go',
321
+ rs: 'ftype.rust',
322
+ php: 'ftype.php',
323
+ rb: 'ftype.ruby',
324
+ java: 'ftype.java',
325
+ kt: 'ftype.kotlin',
326
+ swift: 'ftype.swift',
327
+ c: 'ftype.c',
328
+ h: 'ftype.c',
329
+ cpp: 'ftype.cpp',
330
+ hpp: 'ftype.cpp',
331
+ cs: 'ftype.csharp',
332
+ // Structured text that is code by family but not a language.
333
+ css: 'ftype.stylesheet',
334
+ scss: 'ftype.stylesheet',
335
+ less: 'ftype.stylesheet',
336
+ html: 'ftype.html',
337
+ htm: 'ftype.html',
338
+ json: 'ftype.json',
339
+ yml: 'ftype.yaml',
340
+ yaml: 'ftype.yaml',
341
+ xml: 'ftype.xml',
342
+ toml: 'ftype.toml',
343
+ sql: 'ftype.sql',
344
+ sh: 'ftype.shell',
345
+ bash: 'ftype.shell',
346
+ zsh: 'ftype.shell',
347
+ ps1: 'ftype.powershell',
348
+ // Plain-text family members that are each their own thing.
349
+ md: 'ftype.markdown',
350
+ markdown: 'ftype.markdown',
351
+ txt: 'ftype.plaintext',
352
+ log: 'ftype.log',
353
+ ini: 'ftype.config',
354
+ conf: 'ftype.config',
355
+ cfg: 'ftype.config',
356
+ env: 'ftype.config',
357
+ // Tabular: a `.csv` IS a spreadsheet to the person looking at the column,
358
+ // whatever the program that wrote it was.
359
+ csv: 'ftype.sheet',
360
+ tsv: 'ftype.sheet',
361
+ // Design files. No icon family of their own (they fall to `unknown`, which
362
+ // is a decision for the GLYPH, not for the word) — naming them still beats
363
+ // printing "FIG".
364
+ fig: 'ftype.figma',
365
+ sketch: 'ftype.design',
366
+ xd: 'ftype.design',
367
+ psd: 'ftype.design',
368
+ ai: 'ftype.design',
369
+ };
370
+
371
+ /** Family → i18n key, the second tier. `unknown` is absent on purpose: it is
372
+ * what sends a node to the uppercased-extension fallback. */
373
+ const FAMILY_TYPE_KEYS: Partial<Record<IconFamily, string>> = {
374
+ folder: 'node.folder',
375
+ image: 'ftype.image',
376
+ video: 'ftype.video',
377
+ audio: 'ftype.audio',
378
+ pdf: 'ftype.pdf',
379
+ doc: 'ftype.document',
380
+ sheet: 'ftype.sheet',
381
+ slides: 'ftype.slides',
382
+ archive: 'ftype.archive',
383
+ code: 'ftype.code',
384
+ text: 'ftype.plaintext',
385
+ };
386
+
387
+ /**
388
+ * The i18n key naming this node's kind, or null when nothing maps it and the
389
+ * caller should fall back to the extension. Directories answer
390
+ * `node.folder` — the key the grid card's caption already prints, not a
391
+ * second string saying the same word.
392
+ */
393
+ export function typeLabelKey(node: {
394
+ type?: string;
395
+ extension?: string | null;
396
+ }): string | null {
397
+ if (node.type === 'dir') return 'node.folder';
398
+ const ext = (node.extension || '').trim().toLowerCase();
399
+ const own = EXT_TYPE_KEYS[ext];
400
+ if (own) return own;
401
+ return FAMILY_TYPE_KEYS[iconFamilyFor(node)] ?? null;
402
+ }
403
+
404
+ /**
405
+ * The finished words for the Type column and anything else that names a
406
+ * kind: the mapped name, else the uppercased extension, else an em dash.
407
+ *
408
+ * ⚠ The fallback lives HERE rather than at each call site. Two callers each
409
+ * writing `key ? t(key) : ext.toUpperCase()` is two chances to render an
410
+ * empty cell, and the first one to forget the `|| '—'` prints nothing at all
411
+ * for a file with no extension.
412
+ */
413
+ export function typeLabelFor(
414
+ node: { type?: string; extension?: string | null },
415
+ t: (key: string) => string,
416
+ ): string {
417
+ const key = typeLabelKey(node);
418
+ if (key) return t(key);
419
+ const ext = (node.extension || '').trim();
420
+ return ext ? ext.toUpperCase() : '—';
421
+ }