@brftech/filex-core 0.42.0 → 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 (40) hide show
  1. package/dist/{ArchiveViewer-CGl_J_qE.js → ArchiveViewer-BUQWLhwX.js} +2 -2
  2. package/dist/{ArchiveViewer-CGl_J_qE.js.map → ArchiveViewer-BUQWLhwX.js.map} +1 -1
  3. package/dist/{CsvViewer-D-na6SEG.js → CsvViewer-Bg2_aqzc.js} +2 -2
  4. package/dist/{CsvViewer-D-na6SEG.js.map → CsvViewer-Bg2_aqzc.js.map} +1 -1
  5. package/dist/{DrawioViewer-BFkTSrNc.js → DrawioViewer-CC6rJXeP.js} +2 -2
  6. package/dist/{DrawioViewer-BFkTSrNc.js.map → DrawioViewer-CC6rJXeP.js.map} +1 -1
  7. package/dist/{EpubViewer-BfRE0DTL.js → EpubViewer-CAoTSXTO.js} +2 -2
  8. package/dist/{EpubViewer-BfRE0DTL.js.map → EpubViewer-CAoTSXTO.js.map} +1 -1
  9. package/dist/{IpynbViewer-BpBlICH-.js → IpynbViewer-D0GwGpnE.js} +2 -2
  10. package/dist/{IpynbViewer-BpBlICH-.js.map → IpynbViewer-D0GwGpnE.js.map} +1 -1
  11. package/dist/{MermaidViewer-Ry5Ma463.js → MermaidViewer-DGwAvRZg.js} +2 -2
  12. package/dist/{MermaidViewer-Ry5Ma463.js.map → MermaidViewer-DGwAvRZg.js.map} +1 -1
  13. package/dist/{PsdViewer-BmTlq1Gh.js → PsdViewer-DhGweA0m.js} +2 -2
  14. package/dist/{PsdViewer-BmTlq1Gh.js.map → PsdViewer-DhGweA0m.js.map} +1 -1
  15. package/dist/{TiffViewer-DE5bKDDx.js → TiffViewer-D0AurvAh.js} +2 -2
  16. package/dist/{TiffViewer-DE5bKDDx.js.map → TiffViewer-D0AurvAh.js.map} +1 -1
  17. package/dist/{Viewer3D-DefcNYOm.js → Viewer3D-CdZeK4j4.js} +2 -2
  18. package/dist/{Viewer3D-DefcNYOm.js.map → Viewer3D-CdZeK4j4.js.map} +1 -1
  19. package/dist/filex-core.js +1 -1
  20. package/dist/filex-core.umd.cjs +50 -50
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/{index-B0N5qiyj.js → index-BArI-B1M.js} +9818 -9725
  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 +165 -90
  28. package/src/components/HomeView.vue +6 -3
  29. package/src/components/RecentlyOpened.vue +16 -2
  30. package/src/components/S3KeysPanel.vue +33 -27
  31. package/src/components/SSHKeysPanel.vue +29 -25
  32. package/src/components/SideNav.vue +22 -0
  33. package/src/components/TokensPanel.vue +26 -22
  34. package/src/composables/useScrolledX.ts +21 -0
  35. package/src/lib/nodeRow.ts +106 -0
  36. package/src/locales/en.ts +8 -2
  37. package/src/locales/tr.ts +4 -3
  38. package/src/styles/base.css +24 -0
  39. package/src/types/FileNode.ts +8 -0
  40. package/dist/index-B0N5qiyj.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.42.0",
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';
@@ -332,6 +333,10 @@ const files = ref<FileNode[]>([]);
332
333
  // RBAC effective level for the current directory ('' = ACL not enforced on
333
334
  // this storage → no gating). Drives which write/manage actions are offered.
334
335
  const dirPerm = ref<string>('');
336
+ /** The listed folder sits on a read-only storage (`read_only` on the index
337
+ * response). Folded into permCanEdit so every write affordance — toolbar,
338
+ * sidebar menu, context menu, drop zone, editor mode — reads the same fact. */
339
+ const dirReadOnly = ref(false);
335
340
  // The dead deep-link state: set to the requested path when a listing came
336
341
  // back 404 (folder doesn't exist) or 403 (RBAC-hidden — rendered identically
337
342
  // on purpose so a denied folder doesn't reveal that it exists). '' = none.
@@ -995,19 +1000,42 @@ function onNodeTagsChanged() {
995
1000
  }
996
1001
 
997
1002
  function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
998
- // RecentlyOpened emits the bare row synthesize a FileNode shaped
999
- // enough for openNode to route into the editor / preview.
1000
- const node = {
1001
- type: 'file',
1002
- path: entry.path,
1003
- basename: entry.name,
1004
- extension: (entry.name.split('.').pop() || '').toLowerCase(),
1005
- id: entry.id,
1006
- } 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);
1007
1018
  showRecents.value = false;
1008
1019
  openNode(node);
1009
1020
  }
1010
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
+
1011
1039
  // Resolution order for each external viewer: explicit config override → live
1012
1040
  // backend probe. The probe is the source of truth: an operator can flip the
1013
1041
  // service "on" but if last_check failed (state='error') we still hide the
@@ -1589,15 +1617,22 @@ function onToolbarSearch(v: string) {
1589
1617
  * searches what is on screen.
1590
1618
  */
1591
1619
  const driveScopeLabel = computed(() => {
1592
- /* gorunum:v3-shell Home is not a place to search IN. Its three blocks are
1593
- an overview of everything, so "Search in Home" would name a scope that
1594
- does not exist; the placeholder falls back to the everywhere wording. */
1595
- if (navView.value === 'home') return '';
1596
- if (navView.value === 'tag') return navTag.value;
1597
- if (navView.value) return t(`sidenav.${navView.value}`);
1598
- const rel = currentPath.value.replace(/\/+$/, '');
1599
- const last = rel.split('/').filter(Boolean).pop();
1600
- 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;
1601
1636
  });
1602
1637
 
1603
1638
  /**
@@ -1676,70 +1711,19 @@ async function loadQuota() {
1676
1711
  }
1677
1712
 
1678
1713
  /**
1679
- * nodeRowToFileNode — the starred / recently-opened endpoints answer with raw
1680
- * node rows (relative `path`, numeric `storage_id`), not the listing shape.
1681
- *
1682
- * The storage NAME is what a qualified path needs, and a node row does not
1683
- * carry the id-to-name mapping. The backend fills `storage` for exactly this
1684
- * (handlers/shared.go, attachStorageNames); against an older server the only
1685
- * safe fallback is the single-storage case — guessing in a multi-storage
1686
- * 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.
1687
1721
  */
1688
- /** tablo:t1 — an RFC3339 stamp from a node row as unix ms, or undefined. A
1689
- * string we cannot parse is left undefined rather than turned into `NaN`,
1690
- * which would print as "Invalid Date" and sort unpredictably. */
1691
- function rowMillis(v: unknown): number | undefined {
1692
- if (typeof v !== 'string' || !v) return undefined;
1693
- const ms = Date.parse(v);
1694
- return Number.isFinite(ms) ? ms : undefined;
1695
- }
1696
-
1697
1722
  function nodeRowToFileNode(row: Record<string, unknown>): FileNode | null {
1698
- const rel = String(row?.path ?? '').replace(/^\/+/, '');
1699
- if (!rel) return null;
1700
- const configured = props.config.storages ?? [];
1701
- const storageName =
1702
- typeof row.storage === 'string' && row.storage
1703
- ? row.storage
1704
- : configured.length === 1
1705
- ? configured[0].name
1706
- : '';
1707
- if (multiStorageRoot.value && !storageName) return null;
1708
- const name = String(row.name ?? rel.split('/').pop() ?? '');
1709
- const isDir = row.type === 'dir';
1710
- const size = typeof row.size === 'number' ? row.size : 0;
1711
- const id = typeof row.id === 'number' ? row.id : undefined;
1712
- return {
1713
- type: isDir ? 'dir' : 'file',
1714
- id,
1715
- /* tablo:t1 — ⚠⚠ THE DATE. A node row carries `backend_mtime` (what the
1716
- storage says) and `db_mtime` (what our last scan recorded) as RFC3339
1717
- strings; `FileNode.last_modified` is unix MILLISECONDS. Nothing mapped
1718
- between the two, so every row from Recent, Starred and a tag view
1719
- arrived with no date at all — measured on Recent: eleven rows, eleven em
1720
- dashes in the Modified column, and a Modified column header you could
1721
- click that then sorted nothing. It also made the date grouping this view
1722
- is supposed to show impossible, because every row fell in the "No date"
1723
- bucket. Storage first: `backend_mtime` is the file's own truth and
1724
- `db_mtime` only says when we last looked at it. */
1725
- last_modified: rowMillis(row.backend_mtime) ?? rowMillis(row.db_mtime),
1726
- path: storageName ? `${storageName}://${rel}` : rel,
1727
- basename: name,
1728
- extension: isDir
1729
- ? ''
1730
- : name.includes('.')
1731
- ? (name.split('.').pop() || '').toLowerCase()
1732
- : '',
1733
- storage: storageName,
1734
- visibility: 'private',
1735
- size,
1736
- file_size: size,
1737
- mime_type: typeof row.mime === 'string' ? row.mime : '',
1738
- // Keyed by node id. A file with no rendered thumbnail 404s here and the
1739
- // view falls back to its icon — the contract the ordinary listing has too.
1740
- thumb_url: !isDir && id !== undefined ? `/api/files/thumb/${id}` : undefined,
1741
- extra_metadata: {},
1742
- } as unknown as FileNode;
1723
+ return nodeRowToFileNodePure(row, {
1724
+ storages: props.config.storages ?? [],
1725
+ multiStorageRoot: multiStorageRoot.value,
1726
+ });
1743
1727
  }
1744
1728
 
1745
1729
  /** GET one of the view endpoints. Returns rows already in listing shape. */
@@ -1829,6 +1813,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1829
1813
  navTag.value = '';
1830
1814
  trashMode.value = false;
1831
1815
  e2eRoot.value = '';
1816
+ forgetFolderPerm();
1832
1817
  selection.clear();
1833
1818
  files.value = [];
1834
1819
  dirname.value = NAV_VIEW_DIRNAME.home;
@@ -1862,6 +1847,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
1862
1847
  navTag.value = '';
1863
1848
  trashMode.value = false;
1864
1849
  e2eRoot.value = '';
1850
+ forgetFolderPerm();
1865
1851
  selection.clear();
1866
1852
  try {
1867
1853
  files.value = await fetchNavRows(kind);
@@ -1902,6 +1888,7 @@ async function loadTagView(tag: string) {
1902
1888
  navTag.value = name;
1903
1889
  trashMode.value = false;
1904
1890
  e2eRoot.value = '';
1891
+ forgetFolderPerm();
1905
1892
  selection.clear();
1906
1893
  try {
1907
1894
  const rows = await fetchTaggedRows(name, {
@@ -2034,6 +2021,14 @@ function onInspectorManage(n: FileNode) {
2034
2021
  // (the pre-RBAC default). Otherwise 'editor'/'owner' may write; only 'owner'
2035
2022
  // manages permissions. Enforcement is server-side; this just shapes the menu.
2036
2023
  function permCanEdit(p: string | undefined): boolean {
2024
+ // A read-only storage refuses every write on the server (403 "storage is
2025
+ // read-only") whatever level the ACL grants — an owner of a read-only mount
2026
+ // is an owner who cannot write. Say so here rather than after the click:
2027
+ // until this line the toolbar offered New folder / Upload and the sidebar's
2028
+ // "+ New" menu on a read-only mount, and the user found out from the error
2029
+ // (issue #30). The level itself is left alone: 'owner' still opens the
2030
+ // permissions panel, sharing a read-only file is still allowed.
2031
+ if (dirReadOnly.value) return false;
2037
2032
  // undefined = ACL not enforced (dev / unwired) → full access. In production
2038
2033
  // the backend always sends a level; 'none'/'viewer' cannot write, only
2039
2034
  // 'editor'/'owner' can.
@@ -2042,11 +2037,83 @@ function permCanEdit(p: string | undefined): boolean {
2042
2037
  function permIsOwner(p: string | undefined): boolean {
2043
2038
  return p === 'owner';
2044
2039
  }
2045
- // Effective perm for a selection: a single entry's own perm (falls back to the
2046
- // directory perm), else the directory perm for multi-select / background.
2047
- function selPerm(sel: FileNode[]): string {
2048
- if (sel.length === 1 && typeof sel[0]?.perm === 'string') return sel[0].perm as string;
2049
- 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);
2050
2117
  }
2051
2118
  // Can the current user write into the directory being viewed? Gates the
2052
2119
  // toolbar New Folder / Upload / Paste + drag-drop upload.
@@ -2350,6 +2417,7 @@ async function load(path?: string) {
2350
2417
  adapter.value = resp.adapter;
2351
2418
  dirname.value = resp.dirname;
2352
2419
  dirPerm.value = (resp.perm as string) || '';
2420
+ dirReadOnly.value = resp.read_only === true;
2353
2421
  /* wiring:e2 — backend tells us when this dir sits inside an encrypted
2354
2422
  subtree; '' resets on every plain folder. Drives the lock screen. */
2355
2423
  e2eRoot.value = typeof resp.e2e_root === 'string' ? resp.e2e_root : '';
@@ -3121,7 +3189,7 @@ function openNode(n: FileNode) {
3121
3189
  const ext = (n.extension || '').toLowerCase();
3122
3190
  // RBAC: viewers (no edit on this item) always get the read-only preview
3123
3191
  // modal — never the editable surface. This is the "view vs edit" split.
3124
- previewMode.value = permCanEdit((n.perm as string) ?? dirPerm.value)
3192
+ previewMode.value = nodeCanEdit(n)
3125
3193
  ? previewModeForExt(ext)
3126
3194
  : 'view';
3127
3195
  previewTarget.value = n;
@@ -3223,7 +3291,7 @@ function openNodeInNewTab(n: FileNode) {
3223
3291
  }
3224
3292
  // RBAC: a viewer (no edit on this item) can't use the editable "Aç"
3225
3293
  // surface — drop to the read-only in-page preview instead.
3226
- if (!permCanEdit((n.perm as string) ?? dirPerm.value)) {
3294
+ if (!nodeCanEdit(n)) {
3227
3295
  previewNode(n);
3228
3296
  return;
3229
3297
  }
@@ -3650,7 +3718,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3650
3718
  // RBAC: gate mutating actions when the caller lacks edit on the target. The
3651
3719
  // "İzinler" (permissions) action shows only for owners on RBAC-on storages.
3652
3720
  const p = selPerm(sel);
3653
- 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);
3654
3725
  // Unified "Paylaş / İzinler" popup: public share link (editor+) + per-user
3655
3726
  // permissions (owner-only, decided inside the modal).
3656
3727
  // Unified "Paylaş / İzinler" popup carries the public share link, per-user
@@ -3703,7 +3774,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
3703
3774
  They borrow the clipboard verbs' marks, which is what they are. */
3704
3775
  { key: 'move-to', label: t('ctx.move_to'), icon: 'cut', hidden: !any || !w, disabled: !any },
3705
3776
  { key: 'copy-to', label: t('ctx.copy_to'), icon: 'copy', hidden: !any, disabled: !any },
3706
- { 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 },
3707
3781
  { divider: true, key: 'sep-meta', label: '', hidden: !singleHasId && !canStar },
3708
3782
  /* yildiz:s1 — "star must be an action, like a tag" (owner, v0.30.0).
3709
3783
  Beside Tags on purpose: they are the same kind of verb, and this is the
@@ -4108,7 +4182,7 @@ async function onDocumentCreated(file: { path: string; name: string; ext: string
4108
4182
  previewTarget.value = node;
4109
4183
  // ⚠ NOT previewModeForExt: that sends office types to 'view', which is right
4110
4184
  // for a peek at somebody else's file and wrong for the one you just made.
4111
- previewMode.value = permCanEdit((node.perm as string) ?? dirPerm.value) ? 'edit' : 'view';
4185
+ previewMode.value = nodeCanEdit(node) ? 'edit' : 'view';
4112
4186
  showPreview.value = true;
4113
4187
  emit('file-opened', { path: node.path, basename: node.basename });
4114
4188
  void markRecent(node);
@@ -7281,6 +7355,7 @@ function closeRecoveryKey() {
7281
7355
  :limit="20"
7282
7356
  :refresh-key="recentRefreshKey"
7283
7357
  @open="onRecentOpen"
7358
+ @context="onRecentContext"
7284
7359
  @error="(msg: string) => emit('error', { message: msg, context: { op: 'recents' } })"
7285
7360
  />
7286
7361
  </div>
@@ -144,9 +144,12 @@ const storageTile = fileIconTile({ type: 'dir', mime_type: 'inode/storage' });
144
144
  * with the wrong size line says something false.
145
145
  */
146
146
  function storageCaption(s: HomeStorage): string {
147
- return typeof s.usedBytes === 'number' && s.usedBytes >= 0
148
- ? t('drive.storage.used_unlimited', { used: formatSize(s.usedBytes) })
149
- : t('conn.guide.storage');
147
+ const base =
148
+ typeof s.usedBytes === 'number' && s.usedBytes >= 0
149
+ ? t('drive.storage.used_unlimited', { used: formatSize(s.usedBytes) })
150
+ : t('conn.guide.storage');
151
+ // A read-only mount says so on its card too (issue #30).
152
+ return s.readOnly ? `${base} · ${t('sidenav.storage.readOnly')}` : base;
150
153
  }
151
154
 
152
155
  /**
@@ -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>