@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
@@ -24,6 +24,7 @@ import { hasInternalDrag } from '../lib/dragOut';
24
24
  import { virtualSegmentLabel } from '../lib/listing';
25
25
  import type { LocaleCode } from '../types/ExplorerConfig';
26
26
  import { useLocale } from '../composables/useLocale';
27
+ import { actionIconSvg } from '../lib/actionIcons';
27
28
 
28
29
  const props = defineProps<{
29
30
  dirname: string;
@@ -43,6 +44,17 @@ const props = defineProps<{
43
44
  * relative to it (`/`, `/sub`). Pairs with FileExplorer's rootPath clamp.
44
45
  */
45
46
  rootPath?: string;
47
+ /**
48
+ * gorunum:v2-topbar β€” what is INSIDE the folder the last crumb names, for
49
+ * the chevron the reference shell puts there ("Subfolders"). Supplied by
50
+ * the parent, because the listing it comes from is the parent's: a second
51
+ * fetch from here would be a second answer to "what is in this folder".
52
+ *
53
+ * ⚠ The affordance is drawn whenever the array is PRESENT, empty or not β€”
54
+ * a control that appears and disappears as you walk the tree is a control
55
+ * nobody learns. An empty folder says so in the menu instead.
56
+ */
57
+ subfolders?: { label: string; adapterPath: string }[];
46
58
  }>();
47
59
 
48
60
  // Confined root parsed into its adapter-relative form ("projeler/acme") and
@@ -76,8 +88,13 @@ interface Crumb {
76
88
  }
77
89
 
78
90
  const crumbs = computed<Crumb[]>(() => {
79
- const adapterPrefix = `${props.adapter}://`;
80
- const raw = props.dirname.startsWith(adapterPrefix)
91
+ /* ⚠ Empty adapter β†’ EMPTY prefix, not a bare `://`. A virtual view
92
+ (`.starred`, `.tag~invoices`) belongs to no storage, so the pane hands us
93
+ the sentinel with no adapter at all; gluing `://` onto it would make every
94
+ crumb's `adapterPath` read `://.starred`, which navigates nowhere β€” the
95
+ view's own address is the bare sentinel. */
96
+ const adapterPrefix = props.adapter ? `${props.adapter}://` : '';
97
+ const raw = adapterPrefix && props.dirname.startsWith(adapterPrefix)
81
98
  ? props.dirname.slice(adapterPrefix.length)
82
99
  : props.dirname;
83
100
  const parts = raw.split('/').filter(Boolean);
@@ -96,7 +113,14 @@ const crumbs = computed<Crumb[]>(() => {
96
113
  let acc = floor;
97
114
  for (const part of below.split('/').filter(Boolean)) {
98
115
  acc = acc ? `${acc}/${part}` : part;
99
- out.push({ label: part === '.trash' ? t('node.trash') : part, adapterPath: `${adapterPrefix}${acc}` });
116
+ /* ⚠ THE SAME resolver the unconfined branch below uses. This line used to
117
+ carry a private `part === '.trash' ? t('node.trash') : part` β€” a second
118
+ copy of the sentinel mapping that knew about exactly one sentinel, so a
119
+ confined embed printed `.starred`, `.shared`, `.recent` and
120
+ `.tag~invoices` raw while the panel three inches away named them
121
+ properly. One resolver, and a sentinel added to `lib/listing` reaches
122
+ both branches on the day it lands. */
123
+ out.push({ label: virtualSegmentLabel(part, t) || part, adapterPath: `${adapterPrefix}${acc}` });
100
124
  }
101
125
  return out;
102
126
  }
@@ -110,9 +134,12 @@ const crumbs = computed<Crumb[]>(() => {
110
134
  // root regardless of where we are now.
111
135
  out.push({ label: props.rootLabel || props.adapter, adapterPath: adapterPrefix });
112
136
  }
113
- } else {
137
+ } else if (props.adapter || props.rootLabel) {
114
138
  // Single-storage mode: the storage name IS the root, no "/"
115
139
  // above it.
140
+ // ⚠ …and with NEITHER a storage nor a branded label there is nothing to
141
+ // name: that is a virtual view in a single-storage explorer, where the
142
+ // crumb would otherwise be an empty, clickable box in front of "Starred".
116
143
  out.push({
117
144
  label: props.rootLabel || props.adapter,
118
145
  adapterPath: adapterPrefix,
@@ -139,40 +166,83 @@ const crumbs = computed<Crumb[]>(() => {
139
166
  // into a dropdown. Visible crumbs keep their full click/drag-drop/context
140
167
  // behavior; dropdown entries only navigate.
141
168
  // ------------------------------------------------------------------
169
+ /* gorunum:v2-topbar β€” the home crumb.
170
+ *
171
+ * ⚠ It REPLACES the root crumb, it is not added beside it. In multi-storage
172
+ * mode `crumbs[0]` is a bare "/" whose only job is "back to the storage
173
+ * list" β€” exactly what a house glyph says, and better than a slash nobody
174
+ * reads as a button. Drawing both would be two controls for one destination,
175
+ * which is the duplicate this pass exists to remove.
176
+ * ⚠ Only that "/" crumb is swallowed. A single-storage explorer's first crumb
177
+ * is the storage's NAME and a confined one's is the folder's β€” information a
178
+ * tooltip on a house cannot carry, so those keep their text. */
179
+ const homeCrumb = computed<Crumb | null>(() => {
180
+ const first = crumbs.value[0];
181
+ if (!first) return null;
182
+ return props.multiStorageRoot && first.adapterPath === '' ? first : null;
183
+ });
184
+ /** The crumbs drawn as text β€” everything the home button did not swallow. */
185
+ const trail = computed<Crumb[]>(() =>
186
+ homeCrumb.value ? crumbs.value.slice(1) : crumbs.value,
187
+ );
188
+ /** True while the home button is the current folder (nothing below it). */
189
+ const homeIsCurrent = computed(() => !!homeCrumb.value && trail.value.length === 0);
190
+
142
191
  const OVERFLOW_THRESHOLD = 4;
143
- const collapsed = computed(() => crumbs.value.length > OVERFLOW_THRESHOLD);
192
+ const collapsed = computed(() => trail.value.length > OVERFLOW_THRESHOLD);
144
193
  const leadCrumbs = computed<Crumb[]>(() =>
145
- collapsed.value ? crumbs.value.slice(0, 1) : crumbs.value,
194
+ collapsed.value ? trail.value.slice(0, 1) : trail.value,
146
195
  );
147
196
  const middleCrumbs = computed<Crumb[]>(() =>
148
- collapsed.value ? crumbs.value.slice(1, -2) : [],
197
+ collapsed.value ? trail.value.slice(1, -2) : [],
149
198
  );
150
199
  const tailCrumbs = computed<Crumb[]>(() =>
151
- collapsed.value ? crumbs.value.slice(-2) : [],
200
+ collapsed.value ? trail.value.slice(-2) : [],
152
201
  );
153
202
 
154
203
  const overflowOpen = ref(false);
155
204
  const moreWrapEl = ref<HTMLElement | null>(null);
205
+ /* gorunum:v2-topbar β€” the "Subfolders" chevron's own menu. Separate state
206
+ from the overflow "…", because the two open different lists (crumbs ABOVE
207
+ this folder vs folders INSIDE it) and only ever one at a time. */
208
+ const subOpen = ref(false);
209
+ const subWrapEl = ref<HTMLElement | null>(null);
156
210
 
157
- function onOverflowPick(crumb: Crumb) {
211
+ const anyMenuOpen = computed(() => overflowOpen.value || subOpen.value);
212
+
213
+ function closeMenus() {
158
214
  overflowOpen.value = false;
215
+ subOpen.value = false;
216
+ }
217
+
218
+ function onOverflowPick(crumb: Crumb) {
219
+ closeMenus();
159
220
  emit('navigate', crumb.adapterPath);
160
221
  }
161
222
 
162
- function onDocClick(e: MouseEvent) {
163
- const wrap = moreWrapEl.value;
164
- if (wrap && e.target instanceof Node && wrap.contains(e.target)) return;
223
+ function toggleSub() {
165
224
  overflowOpen.value = false;
225
+ subOpen.value = !subOpen.value;
226
+ }
227
+
228
+ function onDocClick(e: MouseEvent) {
229
+ const t = e.target;
230
+ if (!(t instanceof Node)) {
231
+ closeMenus();
232
+ return;
233
+ }
234
+ if (moreWrapEl.value?.contains(t) || subWrapEl.value?.contains(t)) return;
235
+ closeMenus();
166
236
  }
167
237
 
168
238
  function onDocKeydown(e: KeyboardEvent) {
169
239
  if (e.key === 'Escape') {
170
240
  e.stopPropagation();
171
- overflowOpen.value = false;
241
+ closeMenus();
172
242
  }
173
243
  }
174
244
 
175
- watch(overflowOpen, (v) => {
245
+ watch(anyMenuOpen, (v) => {
176
246
  if (v) {
177
247
  document.addEventListener('click', onDocClick, true);
178
248
  document.addEventListener('keydown', onDocKeydown, true);
@@ -182,10 +252,8 @@ watch(overflowOpen, (v) => {
182
252
  }
183
253
  });
184
254
  // Navigation rebuilds the trail β€” a stale open menu would list crumbs of
185
- // the previous folder.
186
- watch(crumbs, () => {
187
- overflowOpen.value = false;
188
- });
255
+ // the previous folder (and subfolders of the one we just left).
256
+ watch(crumbs, closeMenus);
189
257
  onBeforeUnmount(() => {
190
258
  document.removeEventListener('click', onDocClick, true);
191
259
  document.removeEventListener('keydown', onDocKeydown, true);
@@ -195,6 +263,19 @@ function onClick(crumb: Crumb) {
195
263
  emit('navigate', crumb.adapterPath);
196
264
  }
197
265
 
266
+ /* The home button's three handlers, null-guarded here rather than in the
267
+ * template: `v-if="homeCrumb"` does not narrow the ref inside an event
268
+ * binding, and a `!` in markup is an assertion no reader can check. */
269
+ function onHomeClick() {
270
+ if (homeCrumb.value) onClick(homeCrumb.value);
271
+ }
272
+ function onHomeContext(ev: MouseEvent) {
273
+ if (homeCrumb.value) onContext(ev, homeCrumb.value);
274
+ }
275
+ function onHomeDrop(ev: DragEvent) {
276
+ if (homeCrumb.value) onCrumbDrop(ev, homeCrumb.value);
277
+ }
278
+
198
279
  function onContext(ev: MouseEvent, crumb: Crumb) {
199
280
  ev.preventDefault();
200
281
  ev.stopPropagation();
@@ -307,6 +388,53 @@ function cancelEdit() {
307
388
  <template>
308
389
  <nav class="fe-breadcrumb" aria-label="Breadcrumb">
309
390
  <template v-if="!editing">
391
+ <!-- gorunum:v2-topbar β€” the root crumb. It IS `crumbs[0]` (see the
392
+ script), drawn as a house instead of a "/" nobody reads as a
393
+ button; it is never an extra control beside the root.
394
+
395
+ ⚠⚠ It is named `breadcrumb.root`, NOT `breadcrumb.home`, and that
396
+ was a real collision rather than a synonym. This button goes to
397
+ `adapterPath: ''` β€” the top of the tree: the DRIVES listing where
398
+ several storages are visible, the storage root where one is. The
399
+ panel's own "Home" row goes somewhere else entirely (the overview
400
+ with Storages Β· Recent Β· Starred). Two controls, one word, two
401
+ destinations β€” and the word belonged to the other one. It mattered
402
+ more from 2026-09-13, when "My files" stopped being drawn for a
403
+ multi-storage deployment: this button became the only door to that
404
+ listing, so the one thing it must not do is point at a different
405
+ row in the panel.
406
+
407
+ `breadcrumb.root` rather than a new string because the product
408
+ ALREADY names this destination: FileExplorer labels the tab you
409
+ land on with exactly this key ("Root" / "KΓΆk"). Hovering the house
410
+ and reading the tab now say the same word instead of two.
411
+
412
+ ⚠ Relabelled, NOT repointed. Sending it to the Home overview would
413
+ break two things that are measured: `crumbs[0]` is this crumb, and
414
+ `trail` is `crumbs.slice(1)`, so the root would lose its only crumb
415
+ and (post-"My files") its last door; and `goUp()`/Alt+↑ from a
416
+ storage root loads `''`, so Up and the crumb would then disagree
417
+ about what sits above a storage.
418
+ ⚠ `data-testid` stays `breadcrumb-home`: it is a selector other
419
+ surfaces already use, and renaming it buys nothing. -->
420
+ <button
421
+ v-if="homeCrumb"
422
+ type="button"
423
+ class="fe-breadcrumb__crumb fe-breadcrumb__home"
424
+ :class="{ 'is-last': homeIsCurrent }"
425
+ :aria-current="homeIsCurrent ? 'page' : undefined"
426
+ :title="t('breadcrumb.root')"
427
+ :aria-label="t('breadcrumb.root')"
428
+ data-testid="breadcrumb-home"
429
+ @click="onHomeClick"
430
+ @contextmenu="onHomeContext"
431
+ @dragover="onCrumbDragOver"
432
+ @drop="onHomeDrop"
433
+ >
434
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons -->
435
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('home')"></span>
436
+ <span v-if="!homeIsCurrent" class="fe-breadcrumb__sep" aria-hidden="true">β€Ί</span>
437
+ </button>
310
438
  <button
311
439
  v-for="(c, i) in leadCrumbs"
312
440
  :key="c.adapterPath"
@@ -359,13 +487,57 @@ function cancelEdit() {
359
487
  <span>{{ c.label }}</span>
360
488
  <span v-if="i < tailCrumbs.length - 1" class="fe-breadcrumb__sep" aria-hidden="true">β€Ί</span>
361
489
  </button>
490
+ <!-- gorunum:v2-topbar β€” "Subfolders": the chevron the reference shell
491
+ hangs on the LAST crumb, opening what is inside the folder you are
492
+ standing in. Drawn whenever the parent supplies the list, empty or
493
+ not (see the prop's note). -->
494
+ <span v-if="subfolders" ref="subWrapEl" class="fe-breadcrumb__sub-wrap">
495
+ <button
496
+ type="button"
497
+ class="fe-breadcrumb__sub"
498
+ aria-haspopup="menu"
499
+ :aria-expanded="subOpen"
500
+ :title="t('breadcrumb.subfolders')"
501
+ :aria-label="t('breadcrumb.subfolders')"
502
+ data-testid="breadcrumb-subfolders"
503
+ @click="toggleSub"
504
+ >
505
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons -->
506
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('subfolders')"></span>
507
+ </button>
508
+ <div v-if="subOpen" class="fe-breadcrumb__menu" role="menu">
509
+ <button
510
+ v-for="c in subfolders"
511
+ :key="c.adapterPath"
512
+ type="button"
513
+ class="fe-breadcrumb__menu-item"
514
+ role="menuitem"
515
+ @click="onOverflowPick(c)"
516
+ >{{ c.label }}</button>
517
+ <p v-if="!subfolders.length" class="fe-breadcrumb__menu-empty">
518
+ {{ t('breadcrumb.subfolders.empty') }}
519
+ </p>
520
+ </div>
521
+ </span>
522
+ <!-- gorunum:v1 β€” "type a path and go". The glyph was the ✏ emoji, which
523
+ this row draws from whichever emoji font the machine has: on a
524
+ headless Chromium and on a Linux box without one it comes out as a
525
+ stray dash beside the crumbs. An inline SVG is the same mark
526
+ everywhere and inherits the row's colour.
527
+ gorunum:v2-topbar β€” the mark now comes from lib/actionIcons with
528
+ every other glyph in the row; the reference shell has no path
529
+ editor at all, so this is ours and it stays. -->
362
530
  <button
363
531
  type="button"
364
532
  class="fe-breadcrumb__edit"
365
533
  :title="t('breadcrumb.go_to_path')"
366
534
  :aria-label="t('breadcrumb.go_to_path')"
535
+ data-testid="breadcrumb-edit-path"
367
536
  @click="startEdit"
368
- >✏</button>
537
+ >
538
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons -->
539
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('edit-path')"></span>
540
+ </button>
369
541
  </template>
370
542
  <template v-else>
371
543
  <input
@@ -26,6 +26,8 @@ import type { LocaleCode } from '../types/ExplorerConfig';
26
26
  import type { FileNode, ViewMode } from '../types/FileNode';
27
27
  import type { GlobalSearchHit } from '../composables/useFileApi';
28
28
  import { matchedInContent, snippetSegments } from '../lib/snippet';
29
+ import { actionIconSvg } from '../lib/actionIcons';
30
+ import { fileIconTile } from '../lib/fileIcons';
29
31
  import { useLocale } from '../composables/useLocale';
30
32
 
31
33
  const props = defineProps<{
@@ -93,6 +95,41 @@ type PaletteItem =
93
95
  type HitItem = Extract<PaletteItem, { kind: 'hit' }>;
94
96
  type SavedGroupItem = Extract<PaletteItem, { kind: 'saved' | 'save' }>;
95
97
 
98
+ /* === ikon:emoji β€” the palette's marks =================================
99
+ *
100
+ * `icon` used to be the glyph itself and the template printed it as TEXT:
101
+ * `➜ πŸ“ πŸ“„ ⬆ β–¦ πŸ—‘ ⟳ ↑ 🎨 ⌨ πŸŽ“ ⧉ β—« πŸ’Ύ πŸ”–`, i.e. two colour-emoji sets, a
102
+ * geometric-shapes set and an arrows set, in one 20px column β€” beside a
103
+ * context menu and a toolbar that draw the same actions from
104
+ * `lib/actionIcons`. It is a KEY now, resolved the way ContextMenu resolves
105
+ * a row's key, and the two groups that list FILES resolve through
106
+ * `lib/fileIcons` instead, so a result row in the palette wears the same
107
+ * tile as the same file in the listing behind it.
108
+ */
109
+
110
+ /** `name.ext` β†’ `ext`, for a search hit (which carries no `extension`). */
111
+ function extOf(name: string): string {
112
+ const dot = name.lastIndexOf('.');
113
+ return dot > 0 ? name.slice(dot + 1) : '';
114
+ }
115
+
116
+ /**
117
+ * The row's mark. Static markup from our own two icon modules β€” never a
118
+ * string that came off the wire, which is what lets the template v-html it.
119
+ */
120
+ function iconHtml(it: PaletteItem): string {
121
+ if (it.kind === 'file') return fileIconTile(it.node);
122
+ if (it.kind === 'hit') {
123
+ const name = String(it.hit.name ?? '');
124
+ return fileIconTile({
125
+ type: it.hit.type === 'dir' ? 'dir' : 'file',
126
+ extension: extOf(name),
127
+ basename: name,
128
+ });
129
+ }
130
+ return actionIconSvg(it.icon);
131
+ }
132
+
96
133
  /**
97
134
  * Scored includes: -1 = no match; otherwise earlier + prefix matches rank
98
135
  * higher and shorter names get a small edge. Good enough without a fuzzy
@@ -116,7 +153,7 @@ const gotoItems = computed<PaletteItem[]>(() => {
116
153
  const clean = q.replace(/^\/+|\/+$/g, '');
117
154
  if (!clean) return [];
118
155
  return [
119
- { kind: 'goto', id: `goto:${clean}`, label: `${t('palette.goto')}: ${clean}`, icon: '➜', path: clean },
156
+ { kind: 'goto', id: `goto:${clean}`, label: `${t('palette.goto')}: ${clean}`, icon: 'goto', path: clean },
120
157
  ];
121
158
  });
122
159
 
@@ -130,7 +167,7 @@ const fileItems = computed<PaletteItem[]>(() => {
130
167
  kind: 'file' as const,
131
168
  id: `file:${r.f.path}`,
132
169
  label: r.name,
133
- icon: r.f.type === 'dir' ? 'πŸ“' : 'πŸ“„',
170
+ icon: '', // file rows draw their real tile β€” see iconHtml()
134
171
  node: r.f,
135
172
  }));
136
173
  });
@@ -138,19 +175,21 @@ const fileItems = computed<PaletteItem[]>(() => {
138
175
  const commandItems = computed<PaletteItem[]>(() => {
139
176
  const q = query.value.trim();
140
177
  const defs: Array<{ command: string; label: string; icon: string; enabled: boolean }> = [
141
- { command: 'new-folder', label: t('toolbar.new_folder'), icon: 'πŸ“', enabled: props.canWrite !== false },
142
- { command: 'upload', label: t('toolbar.upload'), icon: '⬆', enabled: props.canWrite !== false },
143
- { command: 'toggle-view', label: t('cmd.view_toggle'), icon: props.viewMode === 'list' ? 'β–¦' : props.viewMode === 'grid' ? 'β–£' : '☰', enabled: true /* wiring:d2 β€” icon of the NEXT mode (listβ†’gridβ†’galleryβ†’list) */ },
144
- { command: 'open-trash', label: t('cmd.trash'), icon: 'πŸ—‘', enabled: true },
145
- { command: 'refresh', label: t('toolbar.refresh'), icon: '⟳', enabled: true },
146
- { command: 'go-up', label: t('toolbar.go_up'), icon: '↑', enabled: props.canGoUp !== false },
178
+ { command: 'new-folder', label: t('toolbar.new_folder'), icon: 'new-folder', enabled: props.canWrite !== false },
179
+ { command: 'upload', label: t('toolbar.upload'), icon: 'upload', enabled: props.canWrite !== false },
180
+ /* wiring:d2 — the mark is the NEXT mode (list→grid→gallery→list), which is
181
+ what pressing the row gets you; the three keys are the ViewSwitcher's own. */
182
+ { command: 'toggle-view', label: t('cmd.view_toggle'), icon: props.viewMode === 'list' ? 'view-grid' : props.viewMode === 'grid' ? 'view-gallery' : 'view-list', enabled: true },
183
+ { command: 'open-trash', label: t('cmd.trash'), icon: 'open-trash', enabled: true },
184
+ { command: 'refresh', label: t('toolbar.refresh'), icon: 'refresh', enabled: true },
185
+ { command: 'go-up', label: t('toolbar.go_up'), icon: 'go-up', enabled: props.canGoUp !== false },
147
186
  /* wiring:int */
148
- { command: 'open-theme', label: t('theme.menu'), icon: '🎨', enabled: true },
149
- { command: 'open-shortcut-settings', label: t('shortcuts.settings.menu'), icon: '⌨', enabled: true },
150
- { command: 'start-tour', label: t('tour.restart'), icon: 'πŸŽ“', enabled: true },
187
+ { command: 'open-theme', label: t('theme.menu'), icon: 'theme', enabled: true },
188
+ { command: 'open-shortcut-settings', label: t('shortcuts.settings.menu'), icon: 'shortcut-settings', enabled: true },
189
+ { command: 'start-tour', label: t('tour.restart'), icon: 'tour', enabled: true },
151
190
  /* wiring:d1 β€” tabs + split */
152
- { command: 'tab-new', label: t('cmd.tab_new'), icon: '⧉', enabled: true },
153
- { command: 'split-toggle', label: t('cmd.split_toggle'), icon: 'β—«', enabled: props.splitEnabled !== false },
191
+ { command: 'tab-new', label: t('cmd.tab_new'), icon: 'tab-new', enabled: true },
192
+ { command: 'split-toggle', label: t('cmd.split_toggle'), icon: 'split', enabled: props.splitEnabled !== false },
154
193
  ];
155
194
  return defs
156
195
  .filter((d) => d.enabled && matchScore(d.label, q) >= 0)
@@ -210,7 +249,7 @@ const hitItems = computed<HitItem[]>(() =>
210
249
  kind: 'hit' as const,
211
250
  id: `hit:${h.storage_id ?? ''}:${h.path ?? ''}:${h.id ?? ''}`,
212
251
  label: String(h.name ?? h.path ?? ''),
213
- icon: h.type === 'dir' ? 'πŸ“' : 'πŸ“„',
252
+ icon: '', // hit rows draw their real tile β€” see iconHtml()
214
253
  hit: h,
215
254
  crumb: hitCrumb(h),
216
255
  inContent: matchedInContent(h.matched),
@@ -264,13 +303,13 @@ const savedItems = computed<SavedGroupItem[]>(() => {
264
303
  const out: SavedGroupItem[] = [];
265
304
  // "Save this query" command β€” only when there IS a query and it isn't saved yet.
266
305
  if (q && !savedSearches.value.includes(q)) {
267
- out.push({ kind: 'save', id: `save:${q}`, label: `${t('palette.save')}: ${q}`, icon: 'πŸ’Ύ', query: q });
306
+ out.push({ kind: 'save', id: `save:${q}`, label: `${t('palette.save')}: ${q}`, icon: 'save-search', query: q });
268
307
  }
269
308
  const scored = savedSearches.value
270
309
  .map((s) => ({ s, sc: matchScore(s, q) }))
271
310
  .filter((r) => r.sc >= 0 || !q);
272
311
  for (const r of scored) {
273
- out.push({ kind: 'saved', id: `saved:${r.s}`, label: r.s, icon: 'πŸ”–', query: r.s });
312
+ out.push({ kind: 'saved', id: `saved:${r.s}`, label: r.s, icon: 'bookmark', query: r.s });
274
313
  }
275
314
  return out;
276
315
  });
@@ -457,7 +496,8 @@ onBeforeUnmount(() => {
457
496
  @mouseenter="active = i"
458
497
  @click="choose(it)"
459
498
  >
460
- <span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
499
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons + lib/fileIcons -->
500
+ <span class="fe-cmdp__icon" aria-hidden="true" v-html="iconHtml(it)"></span>
461
501
  <span class="fe-cmdp__label">{{ it.label }}</span>
462
502
  </button>
463
503
 
@@ -474,7 +514,8 @@ onBeforeUnmount(() => {
474
514
  @mouseenter="active = fileOffset + i"
475
515
  @click="choose(it)"
476
516
  >
477
- <span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
517
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons + lib/fileIcons -->
518
+ <span class="fe-cmdp__icon" aria-hidden="true" v-html="iconHtml(it)"></span>
478
519
  <span class="fe-cmdp__label">{{ it.label }}</span>
479
520
  </button>
480
521
  </template>
@@ -496,7 +537,8 @@ onBeforeUnmount(() => {
496
537
  @mouseenter="active = hitOffset + i"
497
538
  @click="choose(it)"
498
539
  >
499
- <span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
540
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons + lib/fileIcons -->
541
+ <span class="fe-cmdp__icon" aria-hidden="true" v-html="iconHtml(it)"></span>
500
542
  <span class="fe-cmdp__hitbody">
501
543
  <span class="fe-cmdp__hitline">
502
544
  <span class="fe-cmdp__label">{{ it.label }}</span>
@@ -529,7 +571,8 @@ onBeforeUnmount(() => {
529
571
  @click="choose(it)"
530
572
  @keydown.enter.prevent="choose(it)"
531
573
  >
532
- <span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
574
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons + lib/fileIcons -->
575
+ <span class="fe-cmdp__icon" aria-hidden="true" v-html="iconHtml(it)"></span>
533
576
  <span class="fe-cmdp__label">{{ it.label }}</span>
534
577
  <button
535
578
  v-if="it.kind === 'saved'"
@@ -538,7 +581,7 @@ onBeforeUnmount(() => {
538
581
  :title="t('palette.saved.delete')"
539
582
  :aria-label="t('palette.saved.delete')"
540
583
  @click.stop="removeSavedSearch(it.query)"
541
- >πŸ—‘</button>
584
+ ><!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons --><span aria-hidden="true" v-html="actionIconSvg('delete')"></span></button>
542
585
  </div>
543
586
  </template>
544
587
 
@@ -555,7 +598,8 @@ onBeforeUnmount(() => {
555
598
  @mouseenter="active = cmdOffset + i"
556
599
  @click="choose(it)"
557
600
  >
558
- <span class="fe-cmdp__icon" aria-hidden="true">{{ it.icon }}</span>
601
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons + lib/fileIcons -->
602
+ <span class="fe-cmdp__icon" aria-hidden="true" v-html="iconHtml(it)"></span>
559
603
  <span class="fe-cmdp__label">{{ it.label }}</span>
560
604
  </button>
561
605
  </template>
@@ -26,6 +26,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
26
26
  import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
27
27
  import type { StorageRow } from '../types/Connections';
28
28
  import { useLocale } from '../composables/useLocale';
29
+ import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
29
30
  import { useConnections, connectionsOrigin } from '../composables/useConnections';
30
31
  import {
31
32
  buildGuide,
@@ -68,6 +69,7 @@ const {
68
69
  storages,
69
70
  visible,
70
71
  me,
72
+ publicUrl,
71
73
  loading,
72
74
  loaded,
73
75
  error,
@@ -274,7 +276,7 @@ const protocols = guideProtocols();
274
276
  const protocol = ref(protocols[0] ?? 'webdav');
275
277
  const guideStorage = ref<string>('');
276
278
 
277
- const origin = computed(() => connectionsOrigin(props.config));
279
+ const origin = computed(() => connectionsOrigin(props.config, publicUrl.value));
278
280
 
279
281
  /**
280
282
  * What the S3 key panel published: the caller's own key, the endpoint the
@@ -387,7 +389,8 @@ watch(
387
389
  :aria-label="t('conn.close')"
388
390
  @click="emit('close')"
389
391
  >
390
- βœ•
392
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons -->
393
+ <span aria-hidden="true" v-html="actionIconSvg('close')"></span>
391
394
  </button>
392
395
  </header>
393
396
 
@@ -670,6 +673,7 @@ watch(
670
673
  v-if="protocol === 'ftps' || protocol === 'webdav' || protocol === 'mount'"
671
674
  :config="config"
672
675
  :protocol="protocol"
676
+ :host="hostOf(origin)"
673
677
  />
674
678
 
675
679
  <ConnectionGuideView v-if="guide" :guide="guide" :locale="locale" />
@@ -11,13 +11,35 @@ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
11
11
  import type { FileNode } from '../types/FileNode';
12
12
  import { useLocale } from '../composables/useLocale';
13
13
  import { menuShortcutHint } from '../composables/useKeyboardShortcuts';
14
+ import { actionIconSvg } from '../lib/actionIcons';
14
15
 
15
16
  export interface ContextAction {
16
17
  key: string;
17
18
  label: string;
19
+ /**
20
+ * gorunum:v1-icons β€” OPTIONAL icon override. Rows normally need nothing
21
+ * here: the glyph is looked up by `key` in lib/actionIcons. Set this to
22
+ * β€’ another icon key, when one action has two faces (`star` / `unstar`);
23
+ * β€’ any literal string (an emoji), when an embedder wants its own mark β€”
24
+ * an unknown name draws no SVG and the string is printed as-is.
25
+ */
18
26
  icon?: string;
19
27
  danger?: boolean;
20
28
  disabled?: boolean;
29
+ /**
30
+ * tasi:m1 β€” WHY, in one sentence, for a row that is greyed out.
31
+ *
32
+ * ⚠ A disabled control has to explain itself or it is indistinguishable from
33
+ * a broken one. Sharing works on one item at a time, so "Paylaş / İzinler"
34
+ * greys out above a multi-selection β€” grey with a reason reads as a rule,
35
+ * grey without one reads as a bug, and HIDING it (which is what this row did
36
+ * until 2026-09-13) reads as the feature not existing.
37
+ *
38
+ * Rendered as the native `title` on both surfaces. Absent leaves the
39
+ * tooltip each surface already builds β€” the label plus its shortcut in the
40
+ * selection bar, nothing in the menu.
41
+ */
42
+ title?: string;
21
43
  hidden?: boolean;
22
44
  divider?: boolean;
23
45
  /**
@@ -61,6 +83,15 @@ function hintFor(a: ContextAction): string {
61
83
  return menuShortcutHint(a.shortcutId ?? a.key);
62
84
  }
63
85
 
86
+ /* gorunum:v1-icons β€” the row's glyph. `icon` is consulted FIRST because that
87
+ * is where a two-faced action names its variant (`star` vs `unstar`) and where
88
+ * an embedder puts its own mark; the action's own key is the default. An
89
+ * unrecognised name returns '' and the template falls back to printing the
90
+ * string, so a host that still passes an emoji keeps it. */
91
+ function iconFor(a: ContextAction): string {
92
+ return actionIconSvg(a.icon || a.key);
93
+ }
94
+
64
95
  const open = ref(false);
65
96
  const x = ref(0);
66
97
  const y = ref(0);
@@ -328,10 +359,13 @@ defineExpose({ show, hide });
328
359
  class="fe-ctx__item fe-sheet__item"
329
360
  :class="{ 'is-danger': a.danger, 'is-disabled': a.disabled }"
330
361
  :disabled="a.disabled"
362
+ :title="a.title"
331
363
  role="menuitem"
332
364
  @click="pick(a)"
333
365
  >
334
- <span v-if="a.icon" class="fe-ctx__icon" aria-hidden="true">{{ a.icon }}</span>
366
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons -->
367
+ <span v-if="iconFor(a)" class="fe-ctx__icon" aria-hidden="true" v-html="iconFor(a)"></span>
368
+ <span v-else-if="a.icon" class="fe-ctx__icon" aria-hidden="true">{{ a.icon }}</span>
335
369
  <span class="fe-ctx__label">{{ a.label }}</span>
336
370
  <span v-if="hintFor(a)" class="fe-ctx__key" aria-hidden="true">{{ hintFor(a) }}</span>
337
371
  </button>
@@ -356,10 +390,13 @@ defineExpose({ show, hide });
356
390
  class="fe-ctx__item"
357
391
  :class="{ 'is-danger': a.danger, 'is-disabled': a.disabled }"
358
392
  :disabled="a.disabled"
393
+ :title="a.title"
359
394
  role="menuitem"
360
395
  @click="pick(a)"
361
396
  >
362
- <span v-if="a.icon" class="fe-ctx__icon" aria-hidden="true">{{ a.icon }}</span>
397
+ <!-- eslint-disable-next-line vue/no-v-html β€” static markup from lib/actionIcons -->
398
+ <span v-if="iconFor(a)" class="fe-ctx__icon" aria-hidden="true" v-html="iconFor(a)"></span>
399
+ <span v-else-if="a.icon" class="fe-ctx__icon" aria-hidden="true">{{ a.icon }}</span>
363
400
  <span class="fe-ctx__label">{{ a.label }}</span>
364
401
  <!-- tus:t1 β€” the key that does this from the keyboard, read from
365
402
  the registry so a remap reaches it. Empty for a verb with no