@brftech/filex-core 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -3,7 +3,8 @@
3
3
  * ViewSwitcher — the list / grid / gallery toggle.
4
4
  *
5
5
  * Lifted out of Toolbar.vue unchanged (same classes, same glyphs, same
6
- * `role="tablist"` semantics) because the `drive` profile puts it on the
6
+ * `role="tablist"` semantics the glyphs moved to lib/actionIcons with the
7
+ * rest of the emoji, see `gorunum:v1-icons`) because the `drive` profile puts it on the
7
8
  * breadcrumb row instead of in the header, and two copies of a switcher is
8
9
  * how the two rows end up offering different modes. One component, mounted
9
10
  * in whichever row the profile asks for.
@@ -15,6 +16,7 @@
15
16
  * block's `data-v` hash does not match in the web-component build).
16
17
  */
17
18
  import { useLocale } from '../composables/useLocale';
19
+ import { actionIconSvg } from '../lib/actionIcons';
18
20
  import type { LocaleCode } from '../types/ExplorerConfig';
19
21
  import type { ViewMode } from '../types/FileNode';
20
22
 
@@ -47,7 +49,8 @@ function offers(v: ViewMode): boolean {
47
49
  data-testid="view-list"
48
50
  @click="emit('update:viewMode', 'list')"
49
51
  >
50
- <span class="fe-icon" aria-hidden="true">☰</span>
52
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
53
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('view-list')"></span>
51
54
  </button>
52
55
  <button
53
56
  v-if="offers('grid')"
@@ -61,7 +64,8 @@ function offers(v: ViewMode): boolean {
61
64
  data-testid="view-grid"
62
65
  @click="emit('update:viewMode', 'grid')"
63
66
  >
64
- <span class="fe-icon" aria-hidden="true">▦</span>
67
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
68
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('view-grid')"></span>
65
69
  </button>
66
70
  <button
67
71
  v-if="offers('gallery')"
@@ -75,7 +79,8 @@ function offers(v: ViewMode): boolean {
75
79
  data-testid="view-gallery"
76
80
  @click="emit('update:viewMode', 'gallery')"
77
81
  >
78
- <span class="fe-icon" aria-hidden="true">▣</span>
82
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
83
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('view-gallery')"></span>
79
84
  </button>
80
85
  </div>
81
86
  </template>
@@ -53,11 +53,25 @@ export function connectionsBase(config: ExplorerConfig): string {
53
53
  * The origin a client program should be pointed at.
54
54
  *
55
55
  * The instruction pages are only worth anything if they name the real
56
- * deployment, so this resolves to an absolute origin: the configured
57
- * apiBase when there is one (the desktop app, embeds), otherwise the page's
58
- * own origin (the admin SPA, which is served by the same binary).
56
+ * deployment, so this resolves to an absolute address, best evidence first:
57
+ *
58
+ * 1. `publicUrl` what the SERVER says it is reached at
59
+ * (`/api/files/capabilities` → `public_url`, published only when the
60
+ * operator configured it or the request came in on a tenant's own host);
61
+ * 2. the configured apiBase when it is absolute (the desktop app, embeds
62
+ * that talk to filex directly);
63
+ * 3. the page's own origin (the admin SPA, which the same binary serves).
64
+ *
65
+ * ⚠⚠ The first rung is new and it is the fix. Without it an explorer embedded
66
+ * in ANOTHER app — which proxies `/api` to filex under its own origin, the way
67
+ * work.example.com and fishapp do — printed `https://<that app>/dav/`: an address
68
+ * that reaches the host application, never filex. It also left one guide page
69
+ * speaking with two voices, because its S3 endpoint and SFTP host were always
70
+ * the server's and only the WebDAV / mount lines were the page's.
59
71
  */
60
- export function connectionsOrigin(config: ExplorerConfig): string {
72
+ export function connectionsOrigin(config: ExplorerConfig, publicUrl?: string | null): string {
73
+ const told = String(publicUrl ?? '').trim();
74
+ if (/^https?:\/\/[^/]/i.test(told)) return told.replace(/\/+$/, '');
61
75
  const base = connectionsBase(config);
62
76
  if (/^https?:\/\//i.test(base)) {
63
77
  try {
@@ -80,6 +94,8 @@ export function useConnections(config: ExplorerConfig) {
80
94
  /** Storage names a non-admin may see (manager root). */
81
95
  const visible = shallowRef<string[]>([]);
82
96
  const me = ref<ConnectionsUser | null>(null);
97
+ /** The server's own public address, when it has one to give (see connectionsOrigin). */
98
+ const publicUrl = ref<string | null>(null);
83
99
 
84
100
  const loading = ref(false);
85
101
  const loaded = ref(false);
@@ -102,6 +118,19 @@ export function useConnections(config: ExplorerConfig) {
102
118
  loading.value = true;
103
119
  error.value = null;
104
120
  try {
121
+ // Where the server says it lives. Best-effort and in parallel: a server
122
+ // older than `public_url`, or one that has none to give, leaves the
123
+ // guides on the address the page loaded from — exactly as before.
124
+ const told = api
125
+ .capabilities()
126
+ .then((c) => {
127
+ const v = (c as { public_url?: unknown } | null)?.public_url;
128
+ publicUrl.value = typeof v === 'string' && v ? v : null;
129
+ })
130
+ .catch(() => {
131
+ publicUrl.value = null;
132
+ });
133
+
105
134
  // Identity first: the guides need the caller's own e-mail (it IS the
106
135
  // WebDAV username), and it is the one call every role may make.
107
136
  try {
@@ -152,6 +181,7 @@ export function useConnections(config: ExplorerConfig) {
152
181
  visible.value = [];
153
182
  }
154
183
  }
184
+ await told;
155
185
  loaded.value = true;
156
186
  } finally {
157
187
  loading.value = false;
@@ -249,6 +279,7 @@ export function useConnections(config: ExplorerConfig) {
249
279
  storages,
250
280
  visible,
251
281
  me,
282
+ publicUrl,
252
283
  loading,
253
284
  loaded,
254
285
  error,
@@ -0,0 +1,85 @@
1
+ /**
2
+ * useExplorerTimeZone — what ONE explorer instance knows about the clock, fed
3
+ * into `lib/timezone`'s resolver.
4
+ *
5
+ * An explorer knows two of the four tiers and nobody else can know them for
6
+ * it: the zone its host configured (`config.timeZone`, tier `host`) and the
7
+ * account behind the credential it holds (tier `account`). The viewer tier is
8
+ * the browser's and the device tier is `Intl`'s; neither needs an instance.
9
+ *
10
+ * How the account is learned — nothing new on the server:
11
+ *
12
+ * 1. the credential's KIND, from `GET /api/files/capabilities`
13
+ * (`caller_kind`, migration 00030), which the explorer already fetches —
14
+ * or `config.callerKind`, which a host may set to answer sooner and which
15
+ * wins over the server's, exactly as it does for the panel's surfaces;
16
+ * 2. for a person, `GET /api/auth/me` — the route every role may call, open
17
+ * to a bearer token (MiddlewareWithToken) and to cross-origin callers
18
+ * (CORS allows `Authorization`), and already used by the Connections
19
+ * panel for the caller's own e-mail. `user.timezone` is the zone.
20
+ *
21
+ * ⚠⚠ An `app` token skips the tier. It authenticates AS its owner, so
22
+ * `/api/auth/me` would happily answer with that owner's zone — and the embeds
23
+ * we run authenticate every visitor with ONE such token. Imposing its owner's
24
+ * clock on all of them is the exact thing the owner ruled out.
25
+ *
26
+ * ⚠ Unknown kind also skips. When capabilities never lands (an old server, a
27
+ * host that routes nothing but the manager), nobody can say whether the key is
28
+ * a person's, and guessing "person" would be guessing the dangerous way.
29
+ *
30
+ * ⚠ Every failure is silent and falls through: a 401, a 404 from a host that
31
+ * does not proxy `/api/auth/me`, a network error. The tier is withdrawn and
32
+ * the browser's zone answers. A clock must never throw.
33
+ */
34
+
35
+ import { onBeforeUnmount, watch, type Ref } from 'vue';
36
+ import type { ExplorerConfig } from '../types/ExplorerConfig';
37
+ import type { Capabilities } from '../types/FileNode';
38
+ import { releaseTimeZoneOwner, setAccountTimeZone, setHostTimeZone } from '../lib/timezone';
39
+
40
+ export interface ExplorerTimeZoneDeps {
41
+ config: () => ExplorerConfig;
42
+ /** The explorer's capabilities answer; `null` until (and unless) it lands. */
43
+ capabilities: Ref<Capabilities | null>;
44
+ /** `GET /api/auth/me` with this explorer's credential. */
45
+ fetchMe: () => Promise<{ user?: { timezone?: unknown } | null } | undefined>;
46
+ }
47
+
48
+ export function useExplorerTimeZone(deps: ExplorerTimeZoneDeps): void {
49
+ const owner = Symbol('filex-explorer');
50
+
51
+ watch(
52
+ () => deps.config().timeZone,
53
+ (tz) => setHostTimeZone(owner, tz),
54
+ { immediate: true },
55
+ );
56
+
57
+ const kind = (): 'user' | 'app' | undefined =>
58
+ deps.config().callerKind ?? deps.capabilities.value?.caller_kind;
59
+
60
+ let asked = false;
61
+ watch(
62
+ kind,
63
+ (k) => {
64
+ if (k !== 'user') {
65
+ // `app`: nobody's account. Unknown: cannot tell. Either way, withdraw.
66
+ setAccountTimeZone(owner, null);
67
+ return;
68
+ }
69
+ if (asked) return;
70
+ asked = true;
71
+ deps
72
+ .fetchMe()
73
+ .then((body) => {
74
+ // The kind may have changed while the request was out.
75
+ if (kind() !== 'user') return;
76
+ const tz = body?.user?.timezone;
77
+ setAccountTimeZone(owner, typeof tz === 'string' ? tz : null);
78
+ })
79
+ .catch(() => setAccountTimeZone(owner, null));
80
+ },
81
+ { immediate: true },
82
+ );
83
+
84
+ onBeforeUnmount(() => releaseTimeZoneOwner(owner));
85
+ }
@@ -48,6 +48,17 @@ export interface PendingOpDto {
48
48
  created_at: string | null;
49
49
  }
50
50
 
51
+ /** Answer to `?action=newfile` — where the new document actually landed. */
52
+ export interface NewFileResponse {
53
+ /** Adapter-qualified path, ready to hand to the viewer. */
54
+ path: string;
55
+ /** Final basename, which may have gained the extension server-side. */
56
+ name: string;
57
+ ext: string;
58
+ size: number;
59
+ mime: string;
60
+ }
61
+
51
62
  export interface ManagerResponse {
52
63
  adapter: string;
53
64
  storages: string[];
@@ -504,6 +515,27 @@ export function useFileApi(config: ExplorerConfig) {
504
515
  });
505
516
  }
506
517
 
518
+ /**
519
+ * Create an empty document of a known type in `path`.
520
+ *
521
+ * `type` is an extension from `capabilities.newdoc_types` — the registry the
522
+ * SERVER compiled in, not a list the client keeps. That matters for the
523
+ * office formats: a .docx is a ZIP of XML parts, so "create an empty file"
524
+ * has to be answered by whoever holds the template bytes, and the client
525
+ * cannot manufacture one.
526
+ *
527
+ * `name` may or may not already carry the extension; the server appends it
528
+ * when it is missing. Throws on a name collision (409 NAME_TAKEN) — the
529
+ * dialog warns first, but this is the check.
530
+ */
531
+ async function newFile(path: string, name: string, type: string): Promise<NewFileResponse> {
532
+ return jsonFetch<NewFileResponse>(managerUrl('newfile'), {
533
+ method: 'POST',
534
+ headers: { 'Content-Type': 'application/json' },
535
+ body: JSON.stringify({ path, name, type }),
536
+ });
537
+ }
538
+
507
539
  async function rename(path: string, item: string, name: string): Promise<ManagerResponse> {
508
540
  return jsonFetch<ManagerResponse>(managerUrl('rename'), {
509
541
  method: 'POST',
@@ -577,11 +609,18 @@ export function useFileApi(config: ExplorerConfig) {
577
609
  /**
578
610
  * Restore soft-deleted nodes by their node id. The filex backend restores
579
611
  * one node per call (`POST {node_id}`), so we fan out and tally successes.
612
+ *
613
+ * `taken` names the entries the server refused because something already
614
+ * holds their original path (409 `EXISTS`). That refusal is the server
615
+ * protecting the file that holds the name — a restore used to overwrite it —
616
+ * so it is reported by name rather than folded into "0 items restored",
617
+ * which would read as if nothing had been tried.
580
618
  */
581
- async function restoreIds(ids: number[]): Promise<{ restored: number }> {
619
+ async function restoreIds(ids: number[]): Promise<{ restored: number; taken: string[] }> {
582
620
  const url = endpoints.trashRestore;
583
621
  if (!url) throw new Error('trashRestore endpoint not configured');
584
622
  let restored = 0;
623
+ const taken: string[] = [];
585
624
  for (const id of ids) {
586
625
  try {
587
626
  await jsonFetch(url, {
@@ -590,11 +629,20 @@ export function useFileApi(config: ExplorerConfig) {
590
629
  body: JSON.stringify({ node_id: id }),
591
630
  });
592
631
  restored++;
593
- } catch {
594
- /* skip individual failures; report the count that succeeded */
632
+ } catch (err) {
633
+ const e = err as { status?: number; detail?: string };
634
+ if (e.status === 409) {
635
+ try {
636
+ const body = JSON.parse(e.detail ?? '') as { code?: string; name?: string };
637
+ if (body.code === 'EXISTS') taken.push(body.name || String(id));
638
+ } catch {
639
+ /* a 409 without the envelope is counted as a plain failure */
640
+ }
641
+ }
642
+ /* any other failure: skip it, report the count that succeeded */
595
643
  }
596
644
  }
597
- return { restored };
645
+ return { restored, taken };
598
646
  }
599
647
 
600
648
  /**
@@ -934,6 +982,7 @@ export function useFileApi(config: ExplorerConfig) {
934
982
  quotaMe /* surucu:d1 */,
935
983
  subfolders,
936
984
  newFolder,
985
+ newFile,
937
986
  rename,
938
987
  move,
939
988
  copy,
@@ -47,6 +47,22 @@ export interface ShortcutHandlers {
47
47
  onTabNext?: () => void; // Ctrl+Tab
48
48
  onTabPrev?: () => void; // Ctrl+Shift+Tab
49
49
  /* /wiring:d1 */
50
+ /* tus:t1 — the verbs that were only ever reachable from the menus. Every
51
+ * one of them is remappable like the rest; the ones that ship unbound are
52
+ * there so the user can give them a key, not because they do less. */
53
+ onNewFolder?: () => void; // Shift+N
54
+ onUpload?: () => void; // U
55
+ onRefresh?: () => void; // R
56
+ onDownload?: () => void; // D
57
+ onPreview?: () => void; // P
58
+ onShare?: () => void; // Shift+S
59
+ onTags?: () => void; // T
60
+ onConvert?: () => void; // (unbound)
61
+ onOpenTab?: () => void; // (unbound)
62
+ onCopyPath?: () => void; // (unbound)
63
+ onCopyId?: () => void; // (unbound)
64
+ onRestore?: () => void; // (unbound)
65
+ /* /tus:t1 */
50
66
  hasSelection?: () => boolean; // disambiguates Backspace
51
67
  }
52
68
 
@@ -77,6 +93,33 @@ export interface ShortcutActionDef {
77
93
  inForms?: boolean;
78
94
  }
79
95
 
96
+ /**
97
+ * Combos the BROWSER takes before the page sees them. `preventDefault()`
98
+ * cannot stop these in an ordinary tab — Chrome and Firefox handle them at
99
+ * the window level — so an action bound to one of them only ever fires in
100
+ * the desktop app, an installed PWA or a kiosk window.
101
+ *
102
+ * They are not forbidden as DEFAULTS (the tab actions have shipped on
103
+ * Ctrl+T/W/Tab since wiring:d1 and work in the desktop app), but the
104
+ * settings modal refuses to let a user assign one: choosing a key that
105
+ * silently does nothing where you are standing is not a choice, it is a
106
+ * trap. `isReservedCombo` is the one place that list lives.
107
+ */
108
+ const RESERVED_COMBOS = new Set([
109
+ 'Ctrl+N', 'Ctrl+Shift+N',
110
+ 'Ctrl+T', 'Ctrl+Shift+T',
111
+ 'Ctrl+W', 'Ctrl+Shift+W',
112
+ 'Ctrl+Tab', 'Ctrl+Shift+Tab',
113
+ 'Ctrl+Q',
114
+ 'Ctrl+Shift+I', 'Ctrl+Shift+J', 'Ctrl+Shift+C',
115
+ 'F11', 'F12',
116
+ ]);
117
+
118
+ /** Does the browser eat this combo before the page can see it? */
119
+ export function isReservedCombo(combo: string): boolean {
120
+ return RESERVED_COMBOS.has(combo);
121
+ }
122
+
80
123
  /** Registry order = help/settings display order. */
81
124
  export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
82
125
  // Navigation
@@ -109,6 +152,25 @@ export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
109
152
  { id: 'tab-next', defaultCombo: 'Ctrl+Tab', labelKey: 'shortcuts.tab_next', groupKey: 'shortcuts.group.tabs' },
110
153
  { id: 'tab-prev', defaultCombo: 'Ctrl+Shift+Tab', labelKey: 'shortcuts.tab_prev', groupKey: 'shortcuts.group.tabs' },
111
154
  /* /wiring:d1 */
155
+ /* tus:t1 — the rest of the menu verbs. Until now these were reachable only
156
+ * by right-clicking, and the right-click menu named no keys at all, so 16 of
157
+ * the registry's actions were invisible unless somebody opened the `?` sheet
158
+ * on their own. The ones with no default are deliberate: they are rare
159
+ * enough that taking a key from the user by default is the wrong trade, and
160
+ * they are in the registry so the user can take one. */
161
+ { id: 'new-folder', defaultCombo: 'Shift+N', labelKey: 'shortcuts.new_folder', groupKey: 'shortcuts.group.file' },
162
+ { id: 'upload', defaultCombo: 'U', labelKey: 'shortcuts.upload', groupKey: 'shortcuts.group.file' },
163
+ { id: 'refresh', defaultCombo: 'R', labelKey: 'shortcuts.refresh', groupKey: 'shortcuts.group.nav' },
164
+ { id: 'download', defaultCombo: 'D', labelKey: 'shortcuts.download', groupKey: 'shortcuts.group.file' },
165
+ { id: 'preview', defaultCombo: 'P', labelKey: 'shortcuts.preview', groupKey: 'shortcuts.group.nav' },
166
+ { id: 'share', defaultCombo: 'Shift+S', labelKey: 'shortcuts.share', groupKey: 'shortcuts.group.file' },
167
+ { id: 'tags', defaultCombo: 'T', labelKey: 'shortcuts.tags', groupKey: 'shortcuts.group.file' },
168
+ { id: 'convert', defaultCombo: '', labelKey: 'shortcuts.convert', groupKey: 'shortcuts.group.file' },
169
+ { id: 'open-tab', defaultCombo: '', labelKey: 'shortcuts.open_tab', groupKey: 'shortcuts.group.tabs' },
170
+ { id: 'copy-path', defaultCombo: '', labelKey: 'shortcuts.copy_path', groupKey: 'shortcuts.group.file' },
171
+ { id: 'copy-id', defaultCombo: '', labelKey: 'shortcuts.copy_id', groupKey: 'shortcuts.group.file' },
172
+ { id: 'restore', defaultCombo: '', labelKey: 'shortcuts.restore', groupKey: 'shortcuts.group.file' },
173
+ /* /tus:t1 */
112
174
  ];
113
175
 
114
176
  /** action id → ShortcutHandlers callback name. */
@@ -134,6 +196,19 @@ const HANDLER_KEY: Record<string, keyof ShortcutHandlers> = {
134
196
  'tab-close': 'onTabClose',
135
197
  'tab-next': 'onTabNext',
136
198
  'tab-prev': 'onTabPrev',
199
+ /* tus:t1 */
200
+ 'new-folder': 'onNewFolder',
201
+ upload: 'onUpload',
202
+ refresh: 'onRefresh',
203
+ download: 'onDownload',
204
+ preview: 'onPreview',
205
+ share: 'onShare',
206
+ tags: 'onTags',
207
+ convert: 'onConvert',
208
+ 'open-tab': 'onOpenTab',
209
+ 'copy-path': 'onCopyPath',
210
+ 'copy-id': 'onCopyId',
211
+ restore: 'onRestore',
137
212
  };
138
213
 
139
214
  // --------------------------------------------------------------------
@@ -336,6 +411,53 @@ export function comboLabel(combo: string): string {
336
411
  return isMacLike() ? combo.replace(/\bCtrl\b/g, '⌘') : combo;
337
412
  }
338
413
 
414
+ /**
415
+ * Menu action key → registry action id.
416
+ *
417
+ * The right-click menu, the toolbar and the keyboard all name the same verbs,
418
+ * but the menu's `key` and the registry's `id` are separate vocabularies and
419
+ * two of them genuinely differ (`access` is the share dialog, `details` is the
420
+ * inspector). One map, in one place: a second copy is a second chance to
421
+ * forget, and the symptom would be a menu row that silently stops naming its
422
+ * key while every other row keeps working.
423
+ */
424
+ export const MENU_ACTION_SHORTCUTS: Record<string, string> = {
425
+ open: 'open',
426
+ 'open-tab': 'open-tab',
427
+ preview: 'preview',
428
+ download: 'download',
429
+ convert: 'convert',
430
+ access: 'share',
431
+ details: 'inspector',
432
+ 'copy-id': 'copy-id',
433
+ 'copy-path': 'copy-path',
434
+ rename: 'rename',
435
+ cut: 'cut',
436
+ copy: 'copy',
437
+ paste: 'paste',
438
+ star: 'star',
439
+ tags: 'tags',
440
+ delete: 'delete',
441
+ restore: 'restore',
442
+ 'new-folder': 'new-folder',
443
+ 'toggle-hidden': 'toggle-hidden',
444
+ upload: 'upload',
445
+ refresh: 'refresh',
446
+ };
447
+
448
+ /**
449
+ * What a menu row or a toolbar button should print beside its label: the
450
+ * current combo for the verb it triggers, or '' when it has none (unbound, or
451
+ * not a registry action at all — `keep-local`, the E2E entries and so on).
452
+ */
453
+ export function menuShortcutHint(menuKey: string): string {
454
+ /* Fall back to the key itself: the toolbar's own buttons (go-up, upload,
455
+ * refresh…) pass a registry id directly, and an id nothing knows returns ''
456
+ * rather than throwing. */
457
+ const id = MENU_ACTION_SHORTCUTS[menuKey] ?? menuKey;
458
+ return SHORTCUT_ACTIONS.some((a) => a.id === id) ? shortcutHint(id) : '';
459
+ }
460
+
339
461
  /**
340
462
  * The one way a hint may name a key.
341
463
  *