@brftech/filex-core 0.42.1 → 0.42.2

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 (38) hide show
  1. package/dist/{ArchiveViewer-CLNevfPE.js → ArchiveViewer-BUQWLhwX.js} +2 -2
  2. package/dist/{ArchiveViewer-CLNevfPE.js.map → ArchiveViewer-BUQWLhwX.js.map} +1 -1
  3. package/dist/{CsvViewer-K8XA3z7N.js → CsvViewer-Bg2_aqzc.js} +2 -2
  4. package/dist/{CsvViewer-K8XA3z7N.js.map → CsvViewer-Bg2_aqzc.js.map} +1 -1
  5. package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-CC6rJXeP.js} +2 -2
  6. package/dist/{DrawioViewer-BuhZOAx6.js.map → DrawioViewer-CC6rJXeP.js.map} +1 -1
  7. package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-CAoTSXTO.js} +2 -2
  8. package/dist/{EpubViewer-DS3mb3k8.js.map → EpubViewer-CAoTSXTO.js.map} +1 -1
  9. package/dist/{IpynbViewer-BTk7p4Dn.js → IpynbViewer-D0GwGpnE.js} +2 -2
  10. package/dist/{IpynbViewer-BTk7p4Dn.js.map → IpynbViewer-D0GwGpnE.js.map} +1 -1
  11. package/dist/{MermaidViewer-DqucH_io.js → MermaidViewer-DGwAvRZg.js} +2 -2
  12. package/dist/{MermaidViewer-DqucH_io.js.map → MermaidViewer-DGwAvRZg.js.map} +1 -1
  13. package/dist/{PsdViewer-CwiVENmq.js → PsdViewer-DhGweA0m.js} +2 -2
  14. package/dist/{PsdViewer-CwiVENmq.js.map → PsdViewer-DhGweA0m.js.map} +1 -1
  15. package/dist/{TiffViewer-CpewSVmQ.js → TiffViewer-D0AurvAh.js} +2 -2
  16. package/dist/{TiffViewer-CpewSVmQ.js.map → TiffViewer-D0AurvAh.js.map} +1 -1
  17. package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-CdZeK4j4.js} +2 -2
  18. package/dist/{Viewer3D-CIrOn63I.js.map → Viewer3D-CdZeK4j4.js.map} +1 -1
  19. package/dist/filex-core.js +1 -1
  20. package/dist/filex-core.umd.cjs +47 -47
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/{index-Dx0IbBcI.js → index-BArI-B1M.js} +9261 -9181
  23. package/dist/index-BArI-B1M.js.map +1 -0
  24. package/dist/index.d.ts +14 -0
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +152 -90
  28. package/src/components/RecentlyOpened.vue +16 -2
  29. package/src/components/S3KeysPanel.vue +33 -27
  30. package/src/components/SSHKeysPanel.vue +29 -25
  31. package/src/components/TokensPanel.vue +26 -22
  32. package/src/composables/useScrolledX.ts +21 -0
  33. package/src/lib/nodeRow.ts +106 -0
  34. package/src/locales/en.ts +7 -2
  35. package/src/locales/tr.ts +3 -3
  36. package/src/styles/base.css +24 -0
  37. package/src/types/FileNode.ts +8 -0
  38. package/dist/index-Dx0IbBcI.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.42.1",
3
+ "version": "0.42.2",
4
4
  "description": "filex core — Vue 3 source of truth for the filex file manager (FileExplorer + ConnectionsPanel SFCs, composables, types)",
5
5
  "type": "module",
6
6
  "main": "./dist/filex-core.umd.cjs",
@@ -161,6 +161,7 @@ import {
161
161
  injectTrashRow,
162
162
  hydrateTrashRow as hydrateTrashRowShared,
163
163
  } from './lib/listing';
164
+ import { nodeRowToFileNode as nodeRowToFileNodePure } from './lib/nodeRow'; /* Recent / Starred / tag / Home rows — one shape, with `perm` + `read_only` */
164
165
  import { iconFamilyFor, isStorageRow } from './lib/fileIcons'; /* pane:p1 — the storage-row predicate's one home */
165
166
  import { actionIconSvg } from './lib/actionIcons'; /* inceleme:r1 — the drop overlay's mark, off the emoji font */
166
167
  import { setNodeStarred } from './lib/star';
@@ -999,19 +1000,42 @@ function onNodeTagsChanged() {
999
1000
  }
1000
1001
 
1001
1002
  function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
1002
- // RecentlyOpened emits the bare row synthesize a FileNode shaped
1003
- // enough for openNode to route into the editor / preview.
1004
- const node = {
1005
- type: 'file',
1006
- path: entry.path,
1007
- basename: entry.name,
1008
- extension: (entry.name.split('.').pop() || '').toLowerCase(),
1009
- id: entry.id,
1010
- } as unknown as FileNode;
1003
+ // RecentlyOpened emits the bare row. The same converter Recent / Starred /
1004
+ // a tag / Home use gives openNode a row with its storage-qualified path AND
1005
+ // its own `perm` + `read_only`, so the editor-vs-preview split reads the
1006
+ // row's level, not the folder's. The bare synthesis stays as the fallback
1007
+ // for a row the converter cannot address (no storage name at a
1008
+ // multi-storage root) — it was the only shape until 2026-09-19.
1009
+ const node =
1010
+ nodeRowToFileNode(entry as unknown as Record<string, unknown>) ??
1011
+ ({
1012
+ type: 'file',
1013
+ path: entry.path,
1014
+ basename: entry.name,
1015
+ extension: (entry.name.split('.').pop() || '').toLowerCase(),
1016
+ id: entry.id,
1017
+ } as unknown as FileNode);
1011
1018
  showRecents.value = false;
1012
1019
  openNode(node);
1013
1020
  }
1014
1021
 
1022
+ /**
1023
+ * Right-click on a row of the Recently-opened tray: the SAME menu a listing
1024
+ * row gets (owner: "CONTEXT MENÜ HER YERDE AYNI OLMALI"). The tray's row is
1025
+ * the raw node row the endpoint answers with, so it goes through
1026
+ * `nodeRowToFileNode` like Recent / Starred / a tag / Home do and arrives
1027
+ * with its own `perm` + `read_only`; `onContextTarget` then treats it like a
1028
+ * Home card — a target outside this pane's listing. The tray closes first,
1029
+ * as it does on open: it is a modal over the listing, and the verb the menu
1030
+ * runs (Rename, Move to…) opens a dialog of its own.
1031
+ */
1032
+ function onRecentContext(row: Record<string, unknown>, ev: MouseEvent) {
1033
+ const node = nodeRowToFileNode(row);
1034
+ if (!node) return;
1035
+ showRecents.value = false;
1036
+ void onContextTarget(node, ev);
1037
+ }
1038
+
1015
1039
  // Resolution order for each external viewer: explicit config override → live
1016
1040
  // backend probe. The probe is the source of truth: an operator can flip the
1017
1041
  // service "on" but if last_check failed (state='error') we still hide the
@@ -1593,15 +1617,22 @@ function onToolbarSearch(v: string) {
1593
1617
  * searches what is on screen.
1594
1618
  */
1595
1619
  const driveScopeLabel = computed(() => {
1596
- /* gorunum:v3-shell Home is not a place to search IN. Its three blocks are
1597
- an overview of everything, so "Search in Home" would name a scope that
1598
- does not exist; the placeholder falls back to the everywhere wording. */
1599
- if (navView.value === 'home') return '';
1600
- if (navView.value === 'tag') return navTag.value;
1601
- if (navView.value) return t(`sidenav.${navView.value}`);
1602
- const rel = currentPath.value.replace(/\/+$/, '');
1603
- const last = rel.split('/').filter(Boolean).pop();
1604
- return last || adapter.value || '';
1620
+ /* The field searches the WHOLE storage (`?action=search` filters by
1621
+ storage id only see handlers/manager.go vfSearch), and at the virtual
1622
+ root every storage. Naming the open FOLDER here ("Search in Photos") was
1623
+ a lie the user found: results came from the whole storage. The scope is
1624
+ the storage; the folder-scoped box is FilterBar's "Filter in this
1625
+ folder…". Home, the virtual root and the cross-storage views (Recent,
1626
+ Starred, Shared, tags) have no single storage → the everywhere wording.
1627
+ `multiStorageRoot` is the MODE (the admin explorer is always in it), not
1628
+ the place: inside a storage the scope is that storage. Gating on the mode
1629
+ printed "Search all storages" on every admin folder (caught by
1630
+ e2e/tests/107 on the v0.42.2 chain). */
1631
+ if (navView.value || atVirtualRoot.value) return '';
1632
+ const name = adapter.value;
1633
+ if (!name) return '';
1634
+ const st = (props.config.storages ?? []).find((s) => s.name === name);
1635
+ return st?.label || name;
1605
1636
  });
1606
1637
 
1607
1638
  /**
@@ -1680,70 +1711,19 @@ async function loadQuota() {
1680
1711
  }
1681
1712
 
1682
1713
  /**
1683
- * nodeRowToFileNode — the starred / recently-opened endpoints answer with raw
1684
- * node rows (relative `path`, numeric `storage_id`), not the listing shape.
1685
- *
1686
- * The storage NAME is what a qualified path needs, and a node row does not
1687
- * carry the id-to-name mapping. The backend fills `storage` for exactly this
1688
- * (handlers/shared.go, attachStorageNames); against an older server the only
1689
- * safe fallback is the single-storage case — guessing in a multi-storage
1690
- * install sends the user to a path in somebody else's drive.
1714
+ * nodeRowToFileNode — the starred / recently-opened / tag endpoints answer
1715
+ * with raw node rows (relative `path`, numeric `storage_id`), not the listing
1716
+ * shape. The conversion itself lives in `lib/nodeRow` (pure, unit-tested);
1717
+ * this binds it to the explorer's config. Every view that lists such rows
1718
+ * Recent, Starred, a tag, the Home cards, the Recently-opened tray — goes
1719
+ * through here, so they all carry the same `perm` + `read_only` the context
1720
+ * menu gates its write verbs on.
1691
1721
  */
1692
- /** tablo:t1 — an RFC3339 stamp from a node row as unix ms, or undefined. A
1693
- * string we cannot parse is left undefined rather than turned into `NaN`,
1694
- * which would print as "Invalid Date" and sort unpredictably. */
1695
- function rowMillis(v: unknown): number | undefined {
1696
- if (typeof v !== 'string' || !v) return undefined;
1697
- const ms = Date.parse(v);
1698
- return Number.isFinite(ms) ? ms : undefined;
1699
- }
1700
-
1701
1722
  function nodeRowToFileNode(row: Record<string, unknown>): FileNode | null {
1702
- const rel = String(row?.path ?? '').replace(/^\/+/, '');
1703
- if (!rel) return null;
1704
- const configured = props.config.storages ?? [];
1705
- const storageName =
1706
- typeof row.storage === 'string' && row.storage
1707
- ? row.storage
1708
- : configured.length === 1
1709
- ? configured[0].name
1710
- : '';
1711
- if (multiStorageRoot.value && !storageName) return null;
1712
- const name = String(row.name ?? rel.split('/').pop() ?? '');
1713
- const isDir = row.type === 'dir';
1714
- const size = typeof row.size === 'number' ? row.size : 0;
1715
- const id = typeof row.id === 'number' ? row.id : undefined;
1716
- return {
1717
- type: isDir ? 'dir' : 'file',
1718
- id,
1719
- /* tablo:t1 — ⚠⚠ THE DATE. A node row carries `backend_mtime` (what the
1720
- storage says) and `db_mtime` (what our last scan recorded) as RFC3339
1721
- strings; `FileNode.last_modified` is unix MILLISECONDS. Nothing mapped
1722
- between the two, so every row from Recent, Starred and a tag view
1723
- arrived with no date at all — measured on Recent: eleven rows, eleven em
1724
- dashes in the Modified column, and a Modified column header you could
1725
- click that then sorted nothing. It also made the date grouping this view
1726
- is supposed to show impossible, because every row fell in the "No date"
1727
- bucket. Storage first: `backend_mtime` is the file's own truth and
1728
- `db_mtime` only says when we last looked at it. */
1729
- last_modified: rowMillis(row.backend_mtime) ?? rowMillis(row.db_mtime),
1730
- path: storageName ? `${storageName}://${rel}` : rel,
1731
- basename: name,
1732
- extension: isDir
1733
- ? ''
1734
- : name.includes('.')
1735
- ? (name.split('.').pop() || '').toLowerCase()
1736
- : '',
1737
- storage: storageName,
1738
- visibility: 'private',
1739
- size,
1740
- file_size: size,
1741
- mime_type: typeof row.mime === 'string' ? row.mime : '',
1742
- // Keyed by node id. A file with no rendered thumbnail 404s here and the
1743
- // view falls back to its icon — the contract the ordinary listing has too.
1744
- thumb_url: !isDir && id !== undefined ? `/api/files/thumb/${id}` : undefined,
1745
- extra_metadata: {},
1746
- } as unknown as FileNode;
1723
+ return nodeRowToFileNodePure(row, {
1724
+ storages: props.config.storages ?? [],
1725
+ multiStorageRoot: multiStorageRoot.value,
1726
+ });
1747
1727
  }
1748
1728
 
1749
1729
  /** GET one of the view endpoints. Returns rows already in listing shape. */
@@ -1833,6 +1813,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1833
1813
  navTag.value = '';
1834
1814
  trashMode.value = false;
1835
1815
  e2eRoot.value = '';
1816
+ forgetFolderPerm();
1836
1817
  selection.clear();
1837
1818
  files.value = [];
1838
1819
  dirname.value = NAV_VIEW_DIRNAME.home;
@@ -1866,6 +1847,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1866
1847
  navTag.value = '';
1867
1848
  trashMode.value = false;
1868
1849
  e2eRoot.value = '';
1850
+ forgetFolderPerm();
1869
1851
  selection.clear();
1870
1852
  try {
1871
1853
  files.value = await fetchNavRows(kind);
@@ -1906,6 +1888,7 @@ async function loadTagView(tag: string) {
1906
1888
  navTag.value = name;
1907
1889
  trashMode.value = false;
1908
1890
  e2eRoot.value = '';
1891
+ forgetFolderPerm();
1909
1892
  selection.clear();
1910
1893
  try {
1911
1894
  const rows = await fetchTaggedRows(name, {
@@ -2054,11 +2037,83 @@ function permCanEdit(p: string | undefined): boolean {
2054
2037
  function permIsOwner(p: string | undefined): boolean {
2055
2038
  return p === 'owner';
2056
2039
  }
2057
- // Effective perm for a selection: a single entry's own perm (falls back to the
2058
- // directory perm), else the directory perm for multi-select / background.
2059
- function selPerm(sel: FileNode[]): string {
2060
- if (sel.length === 1 && typeof sel[0]?.perm === 'string') return sel[0].perm as string;
2061
- return dirPerm.value;
2040
+ /* ⚠⚠ THE CONTEXT MENU MUST BE THE SAME EVERYWHERE (owner, 2026-09-19: "son
2041
+ * kullanılanlar, ana sayfa gibi sayfalarda context menu eksik kalıyor.
2042
+ * CONTEXT MENÜ HER YERDE AYNI OLMALI").
2043
+ *
2044
+ * `dirPerm` / `dirReadOnly` describe the FOLDER being listed, and only
2045
+ * `load()` sets them. Recent, Starred, Shared, a tag view and Home list rows
2046
+ * from every storage and have no folder — so whatever folder was open LAST
2047
+ * answered for them. On the landing page that is no folder at all
2048
+ * (`dirPerm === ''`), and `permCanEdit('')` is false, so the menu on a Home
2049
+ * card or a Recent row came up without Rename / Delete / Move to / Share.
2050
+ * After a writable folder had been visited the stale level said yes to
2051
+ * everything, including rows on a read-only mount. Intermittent, therefore
2052
+ * reported as "eksik kalıyor" rather than "yok".
2053
+ *
2054
+ * Three rules restore one menu:
2055
+ * 1. entering a virtual view forgets the folder (`forgetFolderPerm`), so
2056
+ * nothing stale can leak into it;
2057
+ * 2. a row answers for itself — its own `perm` (every listing carries it
2058
+ * now, `handlers/meta.go`) and its own storage's `read_only`;
2059
+ * 3. a multi-selection is as weak as its weakest row, because the server
2060
+ * refuses the whole batch on the one row the caller may not touch.
2061
+ * What stays hidden in a virtual view is only what is structurally
2062
+ * impossible there — New folder, Upload, Paste need a destination folder
2063
+ * (`atVirtualRoot`).
2064
+ */
2065
+ function forgetFolderPerm() {
2066
+ dirPerm.value = '';
2067
+ dirReadOnly.value = false;
2068
+ }
2069
+ /** A view whose rows span every storage: no folder is behind it. */
2070
+ const inVirtualView = computed(() => !!navView.value && navView.value !== 'trash');
2071
+ /** The row's own level; the folder's when the row has none. In a virtual view
2072
+ * a row without a level (a server older than `handlers/meta.go`'s `perm`)
2073
+ * is ungated — the server enforces, this only shapes the menu — rather than
2074
+ * gated by an empty folder level that would hide every write verb. */
2075
+ function rowPerm(n: FileNode): string | undefined {
2076
+ if (typeof n.perm === 'string') return n.perm;
2077
+ return inVirtualView.value ? undefined : dirPerm.value;
2078
+ }
2079
+ /** The row sits on a read-only storage: its own `read_only` (nav/tag rows),
2080
+ * else the host's storage list — a folder listing's rows carry no flag of
2081
+ * their own because `dirReadOnly` already answers for the whole folder. */
2082
+ function nodeReadOnly(n: FileNode): boolean {
2083
+ if (n.read_only === true) return true;
2084
+ const storage = typeof n.storage === 'string' ? n.storage : '';
2085
+ if (!storage) return false;
2086
+ return (props.config.storages ?? []).some((s) => s.name === storage && s.readOnly === true);
2087
+ }
2088
+ /** May the caller write THIS row — level and read-only mount folded together.
2089
+ * The one predicate behind the editor/preview split and the menu's write
2090
+ * verbs, so a Home card and a listing row cannot disagree. */
2091
+ function nodeCanEdit(n: FileNode): boolean {
2092
+ return permCanEdit(rowPerm(n)) && !nodeReadOnly(n);
2093
+ }
2094
+ const PERM_RANK: Record<string, number> = { none: 0, viewer: 1, editor: 2, owner: 3 };
2095
+ // Effective perm for a selection: a single entry's own perm, the WEAKEST of a
2096
+ // multi-selection's own perms, else the directory perm (background / rows
2097
+ // that carry no level).
2098
+ function selPerm(sel: FileNode[]): string | undefined {
2099
+ if (sel.length === 0) return dirPerm.value;
2100
+ let weakest: string | undefined;
2101
+ let ungated = false;
2102
+ for (const n of sel) {
2103
+ const p = rowPerm(n);
2104
+ if (p === undefined) {
2105
+ ungated = true;
2106
+ continue;
2107
+ }
2108
+ if (weakest === undefined || (PERM_RANK[p] ?? 0) < (PERM_RANK[weakest] ?? 0)) weakest = p;
2109
+ }
2110
+ // An ungated row (no level, unwired ACL) never strengthens a selection
2111
+ // that also holds a gated one; alone, it stays ungated.
2112
+ return weakest ?? (ungated ? undefined : dirPerm.value);
2113
+ }
2114
+ /** Any row of the selection on a read-only storage → the selection is. */
2115
+ function selReadOnly(sel: FileNode[]): boolean {
2116
+ return sel.some(nodeReadOnly);
2062
2117
  }
2063
2118
  // Can the current user write into the directory being viewed? Gates the
2064
2119
  // toolbar New Folder / Upload / Paste + drag-drop upload.
@@ -3134,7 +3189,7 @@ function openNode(n: FileNode) {
3134
3189
  const ext = (n.extension || '').toLowerCase();
3135
3190
  // RBAC: viewers (no edit on this item) always get the read-only preview
3136
3191
  // modal — never the editable surface. This is the "view vs edit" split.
3137
- previewMode.value = permCanEdit((n.perm as string) ?? dirPerm.value)
3192
+ previewMode.value = nodeCanEdit(n)
3138
3193
  ? previewModeForExt(ext)
3139
3194
  : 'view';
3140
3195
  previewTarget.value = n;
@@ -3236,7 +3291,7 @@ function openNodeInNewTab(n: FileNode) {
3236
3291
  }
3237
3292
  // RBAC: a viewer (no edit on this item) can't use the editable "Aç"
3238
3293
  // surface — drop to the read-only in-page preview instead.
3239
- if (!permCanEdit((n.perm as string) ?? dirPerm.value)) {
3294
+ if (!nodeCanEdit(n)) {
3240
3295
  previewNode(n);
3241
3296
  return;
3242
3297
  }
@@ -3663,7 +3718,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3663
3718
  // RBAC: gate mutating actions when the caller lacks edit on the target. The
3664
3719
  // "İzinler" (permissions) action shows only for owners on RBAC-on storages.
3665
3720
  const p = selPerm(sel);
3666
- const w = permCanEdit(p); // may write here
3721
+ // May write to the selection: the weakest row's level, and no row on a
3722
+ // read-only mount — `selReadOnly` is what `dirReadOnly` cannot answer in a
3723
+ // view whose rows come from several storages (Recent, Starred, a tag, Home).
3724
+ const w = permCanEdit(p) && !selReadOnly(sel);
3667
3725
  // Unified "Paylaş / İzinler" popup: public share link (editor+) + per-user
3668
3726
  // permissions (owner-only, decided inside the modal).
3669
3727
  // Unified "Paylaş / İzinler" popup carries the public share link, per-user
@@ -3716,7 +3774,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3716
3774
  They borrow the clipboard verbs' marks, which is what they are. */
3717
3775
  { key: 'move-to', label: t('ctx.move_to'), icon: 'cut', hidden: !any || !w, disabled: !any },
3718
3776
  { key: 'copy-to', label: t('ctx.copy_to'), icon: 'copy', hidden: !any, disabled: !any },
3719
- { key: 'paste', label: t('ctx.paste'), hidden: !w, disabled: !clipboard.value.mode },
3777
+ /* Paste needs a destination folder; a virtual view (Recent / Starred /
3778
+ Shared / a tag / Home) has none, so it is the one write verb the row
3779
+ menu does not carry there — every per-row verb above and below does. */
3780
+ { key: 'paste', label: t('ctx.paste'), hidden: !w || atVirtualRoot.value, disabled: !clipboard.value.mode },
3720
3781
  { divider: true, key: 'sep-meta', label: '', hidden: !singleHasId && !canStar },
3721
3782
  /* yildiz:s1 — "star must be an action, like a tag" (owner, v0.30.0).
3722
3783
  Beside Tags on purpose: they are the same kind of verb, and this is the
@@ -4121,7 +4182,7 @@ async function onDocumentCreated(file: { path: string; name: string; ext: string
4121
4182
  previewTarget.value = node;
4122
4183
  // ⚠ NOT previewModeForExt: that sends office types to 'view', which is right
4123
4184
  // for a peek at somebody else's file and wrong for the one you just made.
4124
- previewMode.value = permCanEdit((node.perm as string) ?? dirPerm.value) ? 'edit' : 'view';
4185
+ previewMode.value = nodeCanEdit(node) ? 'edit' : 'view';
4125
4186
  showPreview.value = true;
4126
4187
  emit('file-opened', { path: node.path, basename: node.basename });
4127
4188
  void markRecent(node);
@@ -7294,6 +7355,7 @@ function closeRecoveryKey() {
7294
7355
  :limit="20"
7295
7356
  :refresh-key="recentRefreshKey"
7296
7357
  @open="onRecentOpen"
7358
+ @context="onRecentContext"
7297
7359
  @error="(msg: string) => emit('error', { message: msg, context: { op: 'recents' } })"
7298
7360
  />
7299
7361
  </div>
@@ -13,6 +13,10 @@ interface RecentNode {
13
13
  name: string;
14
14
  mime?: string;
15
15
  last_opened?: string;
16
+ /** The raw row carries more (`storage`, `perm`, `read_only`, `type`,
17
+ * the mtimes…) — the explorer turns it into a FileNode for the context
18
+ * menu with the same converter Recent / Starred / a tag view use. */
19
+ [k: string]: unknown;
16
20
  }
17
21
 
18
22
  const props = defineProps<{
@@ -30,6 +34,11 @@ const props = defineProps<{
30
34
 
31
35
  const emit = defineEmits<{
32
36
  (e: 'open', node: RecentNode): void;
37
+ /** Right-click on a row. The tray does not build a menu of its own — the
38
+ * explorer opens the ONE menu every listing row gets (owner: "CONTEXT
39
+ * MENÜ HER YERDE AYNI OLMALI"), so a file here offers exactly what the
40
+ * same file offers in its folder. */
41
+ (e: 'context', node: RecentNode, ev: MouseEvent): void;
33
42
  (e: 'error', message: string): void;
34
43
  }>();
35
44
 
@@ -101,8 +110,13 @@ watch(() => props.refreshKey, load);
101
110
  </header>
102
111
 
103
112
  <ul v-if="items.length">
104
- <li v-for="n in items" :key="n.id">
105
- <button type="button" @click="emit('open', n)" class="filex-recent-item">
113
+ <li v-for="n in items" :key="n.id" data-testid="recent-tray-item" :data-fe-path="n.path">
114
+ <button
115
+ type="button"
116
+ class="filex-recent-item"
117
+ @click="emit('open', n)"
118
+ @contextmenu.prevent="emit('context', n, $event)"
119
+ >
106
120
  <span class="filex-recent-name">{{ n.name }}</span>
107
121
  <span class="filex-recent-meta">{{ fmtTime(n.last_opened) }}</span>
108
122
  </button>
@@ -22,6 +22,7 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
22
22
  import type { S3AccessKey } from '../types/S3Keys';
23
23
  import { useLocale } from '../composables/useLocale';
24
24
  import { useS3Keys } from '../composables/useS3Keys';
25
+ import { useScrolledX } from '../composables/useScrolledX';
25
26
  import { resolveLocale } from '../locales/resolve';
26
27
 
27
28
  const props = defineProps<{
@@ -37,6 +38,7 @@ const emit = defineEmits<{
37
38
 
38
39
  const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
39
40
  const { t, formatDate } = useLocale(locale);
41
+ const { scrolledX, onScroll } = useScrolledX();
40
42
 
41
43
  const {
42
44
  keys,
@@ -231,33 +233,35 @@ function scopeOf(k: S3AccessKey): string {
231
233
 
232
234
  <!-- ── the keys ─────────────────────────────────────────────── -->
233
235
  <p v-if="loading" class="fe-s3keys__muted">…</p>
234
- <table v-else-if="keys.length" class="fe-s3keys__table">
235
- <thead>
236
- <tr>
237
- <th>{{ t('conn.s3keys.col.label') }}</th>
238
- <th>{{ t('conn.s3keys.col.key') }}</th>
239
- <th>{{ t('conn.s3keys.col.scope') }}</th>
240
- <th>{{ t('conn.s3keys.col.lastUsed') }}</th>
241
- <th></th>
242
- </tr>
243
- </thead>
244
- <tbody>
245
- <tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
246
- <td>{{ k.label || t('conn.s3keys.noLabel') }}</td>
247
- <td><code>{{ k.access_key_id }}</code></td>
248
- <td>{{ scopeOf(k) }}</td>
249
- <td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.s3keys.neverUsed') }}</td>
250
- <td class="fe-s3keys__actions">
251
- <button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
252
- {{ k.disabled_at ? t('conn.s3keys.enable') : t('conn.s3keys.disable') }}
253
- </button>
254
- <button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(k)">
255
- {{ confirmRevoke === k.id ? t('conn.s3keys.confirm') : t('conn.s3keys.revoke') }}
256
- </button>
257
- </td>
258
- </tr>
259
- </tbody>
260
- </table>
236
+ <div v-else-if="keys.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
237
+ <table class="fe-s3keys__table">
238
+ <thead>
239
+ <tr>
240
+ <th>{{ t('conn.s3keys.col.label') }}</th>
241
+ <th>{{ t('conn.s3keys.col.key') }}</th>
242
+ <th>{{ t('conn.s3keys.col.scope') }}</th>
243
+ <th>{{ t('conn.s3keys.col.lastUsed') }}</th>
244
+ <th></th>
245
+ </tr>
246
+ </thead>
247
+ <tbody>
248
+ <tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
249
+ <td>{{ k.label || t('conn.s3keys.noLabel') }}</td>
250
+ <td><code>{{ k.access_key_id }}</code></td>
251
+ <td>{{ scopeOf(k) }}</td>
252
+ <td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.s3keys.neverUsed') }}</td>
253
+ <td class="fe-s3keys__actions">
254
+ <button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
255
+ {{ k.disabled_at ? t('conn.s3keys.enable') : t('conn.s3keys.disable') }}
256
+ </button>
257
+ <button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(k)">
258
+ {{ confirmRevoke === k.id ? t('conn.s3keys.confirm') : t('conn.s3keys.revoke') }}
259
+ </button>
260
+ </td>
261
+ </tr>
262
+ </tbody>
263
+ </table>
264
+ </div>
261
265
  <p v-else-if="canMint" class="fe-s3keys__muted">{{ t('conn.s3keys.empty') }}</p>
262
266
  </section>
263
267
  </template>
@@ -389,4 +393,6 @@ function scopeOf(k: S3AccessKey): string {
389
393
  white-space: nowrap;
390
394
  text-align: right;
391
395
  }
396
+ /* Its pinning (sticky right, ground, scroll divider) is in styles/base.css
397
+ next to the list view's `.fe-list__col--menu`, so the two stay one model. */
392
398
  </style>
@@ -18,6 +18,7 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
18
18
  import type { FTPSFacts, SSHPublicKey } from '../types/SSHKeys';
19
19
  import { useLocale } from '../composables/useLocale';
20
20
  import { useSSHKeys } from '../composables/useSSHKeys';
21
+ import { useScrolledX } from '../composables/useScrolledX';
21
22
  import { resolveLocale } from '../locales/resolve';
22
23
 
23
24
  const props = defineProps<{
@@ -50,6 +51,7 @@ const emit = defineEmits<{
50
51
 
51
52
  const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
52
53
  const { t, formatDate } = useLocale(locale);
54
+ const { scrolledX, onScroll } = useScrolledX();
53
55
 
54
56
  const { keys, connection, loading, error, canAdd, hasUsableKey, load, add, setDisabled, remove } =
55
57
  useSSHKeys(props.config);
@@ -171,31 +173,33 @@ function fingerprintOf(k: SSHPublicKey): string {
171
173
  </div>
172
174
 
173
175
  <p v-if="loading" class="fe-s3keys__muted">…</p>
174
- <table v-else-if="keys.length" class="fe-s3keys__table">
175
- <thead>
176
- <tr>
177
- <th>{{ t('conn.sshkeys.col.name') }}</th>
178
- <th>{{ t('conn.sshkeys.col.fingerprint') }}</th>
179
- <th>{{ t('conn.sshkeys.col.lastUsed') }}</th>
180
- <th></th>
181
- </tr>
182
- </thead>
183
- <tbody>
184
- <tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
185
- <td>{{ k.name || t('conn.sshkeys.noName') }}</td>
186
- <td><code>{{ fingerprintOf(k) }}</code></td>
187
- <td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.sshkeys.neverUsed') }}</td>
188
- <td class="fe-s3keys__actions">
189
- <button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
190
- {{ k.disabled_at ? t('conn.sshkeys.enable') : t('conn.sshkeys.disable') }}
191
- </button>
192
- <button class="fe-s3keys__link is-danger" :disabled="busy" @click="drop(k)">
193
- {{ confirmRemove === k.id ? t('conn.sshkeys.confirm') : t('conn.sshkeys.remove') }}
194
- </button>
195
- </td>
196
- </tr>
197
- </tbody>
198
- </table>
176
+ <div v-else-if="keys.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
177
+ <table class="fe-s3keys__table">
178
+ <thead>
179
+ <tr>
180
+ <th>{{ t('conn.sshkeys.col.name') }}</th>
181
+ <th>{{ t('conn.sshkeys.col.fingerprint') }}</th>
182
+ <th>{{ t('conn.sshkeys.col.lastUsed') }}</th>
183
+ <th></th>
184
+ </tr>
185
+ </thead>
186
+ <tbody>
187
+ <tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
188
+ <td>{{ k.name || t('conn.sshkeys.noName') }}</td>
189
+ <td><code>{{ fingerprintOf(k) }}</code></td>
190
+ <td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.sshkeys.neverUsed') }}</td>
191
+ <td class="fe-s3keys__actions">
192
+ <button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
193
+ {{ k.disabled_at ? t('conn.sshkeys.enable') : t('conn.sshkeys.disable') }}
194
+ </button>
195
+ <button class="fe-s3keys__link is-danger" :disabled="busy" @click="drop(k)">
196
+ {{ confirmRemove === k.id ? t('conn.sshkeys.confirm') : t('conn.sshkeys.remove') }}
197
+ </button>
198
+ </td>
199
+ </tr>
200
+ </tbody>
201
+ </table>
202
+ </div>
199
203
  <p v-else-if="canAdd" class="fe-s3keys__muted">{{ t('conn.sshkeys.empty') }}</p>
200
204
  </section>
201
205
  </template>
@@ -30,6 +30,7 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
30
30
  import type { ApiToken } from '../types/Tokens';
31
31
  import { useLocale } from '../composables/useLocale';
32
32
  import { useTokens } from '../composables/useTokens';
33
+ import { useScrolledX } from '../composables/useScrolledX';
33
34
  import { resolveLocale } from '../locales/resolve';
34
35
 
35
36
  const props = defineProps<{
@@ -61,6 +62,7 @@ const emit = defineEmits<{
61
62
 
62
63
  const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
63
64
  const { t, formatDate } = useLocale(locale);
65
+ const { scrolledX, onScroll } = useScrolledX();
64
66
 
65
67
  const { tokens, loading, error, canMint, revealed, load, create, remove, dismiss } = useTokens(
66
68
  props.config,
@@ -288,28 +290,30 @@ function usedLabel(row: ApiToken): string {
288
290
  </div>
289
291
 
290
292
  <p v-if="loading" class="fe-s3keys__muted">…</p>
291
- <table v-else-if="tokens.length" class="fe-s3keys__table">
292
- <thead>
293
- <tr>
294
- <th>{{ t('conn.tokens.col.label') }}</th>
295
- <th>{{ t('conn.tokens.col.scopes') }}</th>
296
- <th>{{ t('conn.tokens.col.used') }}</th>
297
- <th></th>
298
- </tr>
299
- </thead>
300
- <tbody>
301
- <tr v-for="row in tokens" :key="row.id">
302
- <td>{{ row.label || '—' }}</td>
303
- <td><code>{{ row.scopes }}</code></td>
304
- <td>{{ usedLabel(row) }}</td>
305
- <td class="fe-s3keys__actions">
306
- <button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(row)">
307
- {{ confirming === row.id ? t('conn.tokens.confirm') : t('conn.tokens.revoke') }}
308
- </button>
309
- </td>
310
- </tr>
311
- </tbody>
312
- </table>
293
+ <div v-else-if="tokens.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
294
+ <table class="fe-s3keys__table">
295
+ <thead>
296
+ <tr>
297
+ <th>{{ t('conn.tokens.col.label') }}</th>
298
+ <th>{{ t('conn.tokens.col.scopes') }}</th>
299
+ <th>{{ t('conn.tokens.col.used') }}</th>
300
+ <th></th>
301
+ </tr>
302
+ </thead>
303
+ <tbody>
304
+ <tr v-for="row in tokens" :key="row.id">
305
+ <td>{{ row.label || '—' }}</td>
306
+ <td><code>{{ row.scopes }}</code></td>
307
+ <td>{{ usedLabel(row) }}</td>
308
+ <td class="fe-s3keys__actions">
309
+ <button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(row)">
310
+ {{ confirming === row.id ? t('conn.tokens.confirm') : t('conn.tokens.revoke') }}
311
+ </button>
312
+ </td>
313
+ </tr>
314
+ </tbody>
315
+ </table>
316
+ </div>
313
317
  <p v-else-if="canMint" class="fe-s3keys__muted">{{ t('conn.tokens.empty') }}</p>
314
318
 
315
319
  <!-- ⚠ Said next to the button rather than in a document nobody opens: a