@brftech/filex-core 0.43.0 → 0.44.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 (60) hide show
  1. package/dist/ArchiveViewer-D_tJxJhJ.js +236 -0
  2. package/dist/ArchiveViewer-D_tJxJhJ.js.map +1 -0
  3. package/dist/{CsvViewer-ByHVK5ga.js → CsvViewer-B95fArq0.js} +2 -2
  4. package/dist/{CsvViewer-ByHVK5ga.js.map → CsvViewer-B95fArq0.js.map} +1 -1
  5. package/dist/{DrawioViewer-Bgrj8-zG.js → DrawioViewer-BAaPMTge.js} +2 -2
  6. package/dist/{DrawioViewer-Bgrj8-zG.js.map → DrawioViewer-BAaPMTge.js.map} +1 -1
  7. package/dist/{EpubViewer-DJwUKlHW.js → EpubViewer-B-B7bRW7.js} +2 -2
  8. package/dist/{EpubViewer-DJwUKlHW.js.map → EpubViewer-B-B7bRW7.js.map} +1 -1
  9. package/dist/{IpynbViewer-Bz0eJ4NS.js → IpynbViewer-COOTyOcu.js} +2 -2
  10. package/dist/{IpynbViewer-Bz0eJ4NS.js.map → IpynbViewer-COOTyOcu.js.map} +1 -1
  11. package/dist/{MermaidViewer-CEDTD_pp.js → MermaidViewer-CjVCmR3s.js} +2 -2
  12. package/dist/{MermaidViewer-CEDTD_pp.js.map → MermaidViewer-CjVCmR3s.js.map} +1 -1
  13. package/dist/{PsdViewer-CZcxUpZO.js → PsdViewer-xsIYL9Gu.js} +2 -2
  14. package/dist/{PsdViewer-CZcxUpZO.js.map → PsdViewer-xsIYL9Gu.js.map} +1 -1
  15. package/dist/{TiffViewer-BwI62CbQ.js → TiffViewer-CtwnTxqL.js} +2 -2
  16. package/dist/{TiffViewer-BwI62CbQ.js.map → TiffViewer-CtwnTxqL.js.map} +1 -1
  17. package/dist/{Viewer3D-DoeZcuMm.js → Viewer3D-xYMwndEy.js} +2 -2
  18. package/dist/{Viewer3D-DoeZcuMm.js.map → Viewer3D-xYMwndEy.js.map} +1 -1
  19. package/dist/filex-core.js +372 -366
  20. package/dist/filex-core.umd.cjs +62 -62
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/{index-DuWnGuv5.js → index-BBToAjwL.js} +16770 -15869
  23. package/dist/index-BBToAjwL.js.map +1 -0
  24. package/dist/index.d.ts +185 -10
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +363 -23
  28. package/src/components/ContextMenu.vue +17 -0
  29. package/src/components/GalleryView.vue +24 -30
  30. package/src/components/GridView.vue +33 -36
  31. package/src/components/HomeView.vue +14 -4
  32. package/src/components/InspectorPanel.vue +9 -3
  33. package/src/components/ListView.vue +21 -14
  34. package/src/components/OperationsCenter.vue +6 -0
  35. package/src/components/PendingOpsTray.vue +5 -3
  36. package/src/components/ThumbTile.vue +58 -0
  37. package/src/composables/useFileApi.ts +36 -6
  38. package/src/composables/useLocale.ts +26 -0
  39. package/src/composables/useOperations.ts +7 -1
  40. package/src/composables/usePendingOps.ts +11 -7
  41. package/src/composables/useThumbs.ts +53 -21
  42. package/src/index.ts +15 -0
  43. package/src/lib/actionIcons.ts +6 -0
  44. package/src/lib/archiveFormats.ts +12 -0
  45. package/src/lib/archivePreviewCache.ts +61 -0
  46. package/src/lib/catalogCoverage.ts +137 -0
  47. package/src/lib/nearViewport.ts +77 -0
  48. package/src/locales/en.ts +67 -0
  49. package/src/locales/tr.ts +65 -0
  50. package/src/modals/ArchiveCreateModal.vue +192 -0
  51. package/src/modals/ArchiveExtractModal.vue +57 -0
  52. package/src/modals/ArchivePasswordModal.vue +62 -0
  53. package/src/modals/PreviewModal.vue +15 -1
  54. package/src/styles/base.css +47 -2
  55. package/src/types/ExplorerConfig.ts +9 -0
  56. package/src/types/FileNode.ts +19 -0
  57. package/src/viewers/ArchiveViewer.vue +145 -40
  58. package/dist/ArchiveViewer-BqU761Wk.js +0 -149
  59. package/dist/ArchiveViewer-BqU761Wk.js.map +0 -1
  60. package/dist/index-DuWnGuv5.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
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",
@@ -22,6 +22,7 @@ import type {
22
22
  ViewMode,
23
23
  ClipboardState,
24
24
  Capabilities,
25
+ ArchiveCreateFormat,
25
26
  } from './types/FileNode';
26
27
  import { isExternalUsable } from './types/FileNode';
27
28
  import { useFileApi, type GlobalSearchHit, type ManagerResponse } from './composables/useFileApi';
@@ -67,7 +68,7 @@ import {
67
68
  import { attachViewPrefsHttp } from './lib/viewPrefsHttp';
68
69
  import { provideTableEnv } from './lib/tableEnv';
69
70
  import { gateOnService, isOfficeExt, legacyConvertGate } from './lib/serviceGate';
70
- import { opFailure } from './lib/errorWords';
71
+ import { opFailure, sayFailure } from './lib/errorWords';
71
72
  import { resolveUiProfile } from './lib/uiProfile';
72
73
  import RecentlyOpened from './components/RecentlyOpened.vue';
73
74
  import {
@@ -96,6 +97,7 @@ import {
96
97
  } from './lib/advSearch' /* gorunum:v1-advsearch */;
97
98
  import ShortcutsHelp from './components/ShortcutsHelp.vue';
98
99
  /* /cila:c wiring */
100
+ import { coverageByStorage, coverageNotice, type CatalogCoverage } from './lib/catalogCoverage';
99
101
  /* wiring:c1 — tema galerisi */
100
102
  import ThemeGallery from './components/ThemeGallery.vue';
101
103
  import {
@@ -201,6 +203,9 @@ import {
201
203
  } from './lib/dragOut';
202
204
 
203
205
  import NewFolderModal from './modals/NewFolderModal.vue';
206
+ import ArchiveCreateModal from './modals/ArchiveCreateModal.vue';
207
+ import ArchiveExtractModal from './modals/ArchiveExtractModal.vue';
208
+ import ArchivePasswordModal from './modals/ArchivePasswordModal.vue';
204
209
  import NewDocumentModal from './modals/NewDocumentModal.vue'; /* belge:n1 */
205
210
  import RenameModal from './modals/RenameModal.vue';
206
211
  import DeleteConfirmModal from './modals/DeleteConfirmModal.vue';
@@ -346,11 +351,16 @@ const pendingOps = usePendingOps(props.config, api, {
346
351
  opUndo.delete(op.id);
347
352
  /* "Empty the trash" says how it ended itself (emptyTrash below), and a
348
353
  * row somebody cancelled is not announced as done. */
349
- if (op.op_type === 'trash-empty' || op.status === 'cancelled') {
354
+ if (op.op_type === 'trash-empty') {
350
355
  void load();
351
356
  return;
352
357
  }
353
- if (op.status === 'error') {
358
+ if (op.status === 'cancelled') {
359
+ const message = op.op_type === 'archive-extract'
360
+ ? t('archive.extraction_cancelled', { count: op.progress_done })
361
+ : t('opc.status.aborted');
362
+ flashToast(message);
363
+ } else if (op.status === 'error') {
354
364
  // Said, not printed: the server's error text is English and sometimes
355
365
  // plumbing ("engine libreoffice is not installed on this host").
356
366
  flashToast(opFailure(op, t).text);
@@ -361,12 +371,16 @@ const pendingOps = usePendingOps(props.config, api, {
361
371
  flashToast(op.message || t('plugin.done', { label: pluginOpLabel(op) }));
362
372
  } else {
363
373
  const verb =
364
- op.op_type === 'copy'
365
- ? t('toast.copied')
366
- : op.op_type === 'move'
367
- ? t('toast.moved')
368
- : t('toast.deleted');
369
- flashToast(`${verb} (${op.progress_total})`);
374
+ op.op_type === 'archive-create'
375
+ ? t('archive.created')
376
+ : op.op_type === 'archive-extract'
377
+ ? t('archive.extraction_completed')
378
+ : op.op_type === 'copy'
379
+ ? t('toast.copied')
380
+ : op.op_type === 'move'
381
+ ? t('toast.moved')
382
+ : t('toast.deleted');
383
+ flashToast(op.op_type.startsWith('archive-') ? verb : `${verb} (${op.progress_total})`);
370
384
  }
371
385
  void load();
372
386
  void splitPaneRef.value?.reload(); /* wiring:d1 — refresh the secondary pane too */
@@ -450,6 +464,14 @@ const searchTruncated = ref(false);
450
464
  * (handlers.Manager, managerSearchPage) — the page an older server's full
451
465
  * answer is recognised by. */
452
466
  const MANAGER_SEARCH_PAGE = 250;
467
+ /**
468
+ * How much of each storage its catalog covers, from the last response that
469
+ * said (`storage_info` on every listing and search — lib/catalogCoverage).
470
+ * Kept across loads: the content search (`/api/files/search`) does not carry
471
+ * it, and its banner still has to name a storage the catalog does not cover.
472
+ */
473
+ const coverageMap = ref<Record<string, CatalogCoverage | null>>({});
474
+ const catalogAllBusy = ref(false);
453
475
  // trashMode — true while viewing the filex trash (soft-deleted nodes from the
454
476
  // backend trash endpoint), entered by opening the virtual `.trash` row and
455
477
  // exited by any normal navigation (load() resets it). Replaces a brittle
@@ -1285,16 +1307,6 @@ async function onSurfaceOpen(plugin: string, req: { path: string; action?: strin
1285
1307
  await openAppTarget({ plugin, action: req.action, view: req.view, path: req.path });
1286
1308
  }
1287
1309
 
1288
- /** Cancel from the operations center — the server stops the job. */
1289
- async function onCancelPendingOp(id: number) {
1290
- try {
1291
- await api.opsCancel(id);
1292
- await pendingOps.poll();
1293
- } catch {
1294
- flashToast(t('plugin.cancel_failed'));
1295
- }
1296
- }
1297
-
1298
1310
  /**
1299
1311
  * "Open" on a finished job: go to the output's folder and select it. The
1300
1312
  * output is an adapter-qualified path (`docs://reports/nda-signed.pdf`); the
@@ -1633,6 +1645,45 @@ const showNewDocument = ref(false); /* belge:n1 */
1633
1645
  const showRename = ref(false);
1634
1646
  const showDelete = ref(false);
1635
1647
  const showPreview = ref(false);
1648
+ const showArchiveCreate = ref(false);
1649
+ const showArchiveExtract = ref(false);
1650
+ const showArchivePassword = ref(false);
1651
+ const archiveTargets = ref<FileNode[]>([]);
1652
+ const archiveTarget = ref<FileNode | null>(null);
1653
+ const archiveDestinationDir = ref('');
1654
+ const archiveBusy = ref(false);
1655
+ const archiveError = ref('');
1656
+ const archiveInPane = ref(false);
1657
+ const archivePasswordError = ref('');
1658
+ const archivePasswordAction = ref<{
1659
+ target: FileNode;
1660
+ dest: string;
1661
+ inPane: boolean;
1662
+ } | null>(null);
1663
+
1664
+ const archiveSuggestedName = computed(() => {
1665
+ if (archiveTargets.value.length !== 1) return 'archive';
1666
+ return archiveTargets.value[0]?.basename.replace(/\.[^.]+$/, '') || 'archive';
1667
+ });
1668
+ const ARCHIVE_CREATE_FORMATS: ArchiveCreateFormat[] = ['zip', '7z', 'tar', 'tar.gz', 'tar.bz2', 'tar.xz'];
1669
+ /* The formats THIS server can make (capabilities.archive): every one but a
1670
+ * plain ZIP needs 7-Zip there, so a server without it offers ZIP alone. A
1671
+ * server with no `archive` block predates archive creation: nothing is
1672
+ * offered, and "Create archive…" is not in the menu. */
1673
+ const archiveAllowedFormats = computed<ArchiveCreateFormat[]>(() => {
1674
+ const configured = capabilitiesData.value?.archive?.allowed_formats ?? [];
1675
+ return configured.filter((format): format is ArchiveCreateFormat => ARCHIVE_CREATE_FORMATS.includes(format));
1676
+ });
1677
+ const archiveDefaultFormat = computed<ArchiveCreateFormat>(() => {
1678
+ const configured = capabilitiesData.value?.archive?.default_format;
1679
+ if (configured && archiveAllowedFormats.value.includes(configured)) return configured;
1680
+ return archiveAllowedFormats.value.includes('7z') ? '7z' : archiveAllowedFormats.value[0] ?? 'zip';
1681
+ });
1682
+ const archiveEncryption = computed(() => capabilitiesData.value?.archive?.encryption !== false);
1683
+ const archiveSuggestedFolder = computed(() =>
1684
+ (archiveTarget.value?.basename || 'archive')
1685
+ .replace(/\.(tar\.(gz|bz2|xz)|zip|7z|rar|tgz|gz|bz2|xz)$/i, '') || 'archive',
1686
+ );
1636
1687
  const renameTarget = ref<FileNode | null>(null);
1637
1688
  /* Why the last rename did not happen, shown in the dialog. A failure used to be
1638
1689
  * only EMITTED, which the stock web app logs to the console: the dialog stayed
@@ -2364,7 +2415,16 @@ const homeLoading = ref(false);
2364
2415
  * pixels to the left is rendering that same array — a Home that asked for its
2365
2416
  * own copy could show a drive the panel beside it hides.
2366
2417
  */
2367
- const homeStorages = computed(() => props.config.storages ?? []);
2418
+ /* A drive's figure is a lower bound while its catalog does not cover all of
2419
+ it: the host says so (`usedPartial`, from the usage endpoint's `coverage`),
2420
+ or else the last listing did (coverageMap). */
2421
+ const homeStorages = computed(() =>
2422
+ (props.config.storages ?? []).map((s) =>
2423
+ s.usedPartial !== undefined || !(s.name in coverageMap.value)
2424
+ ? s
2425
+ : { ...s, usedPartial: coverageMap.value[s.name] !== null },
2426
+ ),
2427
+ );
2368
2428
 
2369
2429
  async function loadHome() {
2370
2430
  homeLoading.value = true;
@@ -2776,6 +2836,50 @@ function showToast(state: ToastState, ms: number) {
2776
2836
  function flashToast(msg: string) {
2777
2837
  showToast({ message: msg }, 2500);
2778
2838
  }
2839
+
2840
+ /**
2841
+ * The catalog-coverage strip over a listing and over search results
2842
+ * (lib/catalogCoverage): what search, folder sizes and usage leave out, and
2843
+ * why. Not over a view that is not a storage's (Home, Recent, the trash) or a
2844
+ * dead link. A search typed at a storage's root spans every storage — the
2845
+ * server's own rule (handlers.Manager vfSearch) — so it names each one that is
2846
+ * not fully cataloged.
2847
+ */
2848
+ const coverageShown = computed(() => {
2849
+ if (navView.value || trashActive.value || notFoundPath.value) return null;
2850
+ const searching = !!searchQuery.value;
2851
+ const atStorageRoot = stripAdapter(dirname.value).replace(/^\/+|\/+$/g, '') === '';
2852
+ return coverageNotice({
2853
+ map: coverageMap.value,
2854
+ adapter: adapter.value,
2855
+ searching,
2856
+ crossStorage: searching && atStorageRoot && Object.keys(coverageMap.value).length > 1,
2857
+ admin: callerAdmin.value,
2858
+ });
2859
+ });
2860
+
2861
+ /**
2862
+ * "Catalog everything" (an administrator, a storage cataloged only on open):
2863
+ * the ordinary full sync of that storage, the one the admin panel's
2864
+ * "Sync now" starts. The storage's id is asked for at click time — the
2865
+ * listing carries names only, and nobody but an administrator needs the id.
2866
+ */
2867
+ async function catalogAll(storage: string | undefined) {
2868
+ if (!storage || catalogAllBusy.value) return;
2869
+ catalogAllBusy.value = true;
2870
+ try {
2871
+ const base = connectionsBase(props.config);
2872
+ const rows = await api.jsonFetch<Array<{ id: number; name: string }>>(`${base}/api/admin/storages`);
2873
+ const row = Array.isArray(rows) ? rows.find((r) => r.name === storage) : undefined;
2874
+ if (!row) throw new Error('no such storage');
2875
+ await api.jsonFetch(`${base}/api/admin/storages/${row.id}/sync`, { method: 'POST' });
2876
+ flashToast(t('coverage.catalog_started'));
2877
+ } catch (err) {
2878
+ flashToast(sayFailure(err, t('toast.failed'), { t, callerAdmin: callerAdmin.value }).text);
2879
+ } finally {
2880
+ catalogAllBusy.value = false;
2881
+ }
2882
+ }
2779
2883
  function undoToast(message: string, undo: () => Promise<void>) {
2780
2884
  showToast({ message, actionLabel: t('toast.undo'), action: undo }, 8000);
2781
2885
  }
@@ -3019,6 +3123,7 @@ async function load(path?: string) {
3019
3123
  // it leaves the full-page guess the advanced search count always made.
3020
3124
  searchTruncated.value =
3021
3125
  !!searchQuery.value && advSearchTruncated(resp.files.length, MANAGER_SEARCH_PAGE, resp.truncated);
3126
+ if (Array.isArray(resp.storage_info)) coverageMap.value = coverageByStorage(resp.storage_info);
3022
3127
  adapter.value = resp.adapter;
3023
3128
  dirname.value = resp.dirname;
3024
3129
  dirPerm.value = (resp.perm as string) || '';
@@ -4402,6 +4507,7 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
4402
4507
  }
4403
4508
  const isFile = single && sel[0]?.type === 'file';
4404
4509
  const tagsLabel = t('ctx.tags_menu');
4510
+ const isArchive = single && isArchiveFile(sel[0]);
4405
4511
  const singleHasId = single && typeof sel[0]?.id === 'number';
4406
4512
  /* yildiz:s1 */
4407
4513
  const canStar = starableNodes(sel).length > 0;
@@ -4457,6 +4563,9 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
4457
4563
  hidden: !single || convertGate.hidden === true || !w || e2eActive.value /* wiring:e2 — convert is meaningless on ciphertext */,
4458
4564
  disabled: !isFile || convertGate.disabled === true,
4459
4565
  },
4566
+ { key: 'archive-create', label: t('ctx.archive_create'), hidden: !any || (single && isArchive) || !w || e2eActive.value || archiveAllowedFormats.value.length === 0, disabled: !any },
4567
+ { key: 'archive-extract', label: t('ctx.archive_extract'), hidden: !isArchive || !w || e2eActive.value, disabled: !isArchive },
4568
+ { key: 'archive-extract-here', icon: 'archive-extract', label: t('archive.extract_here'), hidden: !isArchive || !w || e2eActive.value, disabled: !isArchive },
4460
4569
  /* tasi:m1 — VISIBLE and grey above a multi-selection, not gone. Sharing
4461
4570
  really is one item at a time (a share link addresses one node), and the
4462
4571
  row now says so in its tooltip; vanishing taught the reader that filex
@@ -4515,6 +4624,12 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
4515
4624
  ];
4516
4625
  }
4517
4626
 
4627
+ const ARCHIVE_EXTENSIONS = new Set(['zip', '7z', 'rar', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz']);
4628
+
4629
+ function isArchiveFile(node: FileNode | undefined): boolean {
4630
+ return !!node && node.type === 'file' && ARCHIVE_EXTENSIONS.has((node.extension || '').toLowerCase());
4631
+ }
4632
+
4518
4633
  // toolbarActions — what the top toolbar shows. Mirrors the context menu so the
4519
4634
  // two stay identical for a selection; the empty/trash/virtual-root cases match
4520
4635
  // the context menu's special branches.
@@ -4524,7 +4639,7 @@ const toolbarActions = computed<ContextAction[]>(() => {
4524
4639
  * menu offers (`contextActions`, ctxMode 'pane'). The three special branches
4525
4640
  * below describe the MAIN listing's state — the trash, the multi-storage
4526
4641
  * root — and the split pane reaches neither through this computed. */
4527
- if (paneIsActive.value) return sel.length ? selectionActionList(sel) : [];
4642
+ if (paneIsActive.value) return sel.length ? selectionActionList(sel).filter((a) => a.key !== 'archive-extract-here') : [];
4528
4643
  if (trashActive.value) {
4529
4644
  if (sel.length === 0) return [];
4530
4645
  return [
@@ -4537,7 +4652,7 @@ const toolbarActions = computed<ContextAction[]>(() => {
4537
4652
  return sel.length === 1 ? [{ key: 'open', label: t('ctx.open') }] : [];
4538
4653
  }
4539
4654
  if (sel.length === 0) return [];
4540
- return selectionActionList(sel);
4655
+ return selectionActionList(sel).filter((a) => a.key !== 'archive-extract-here');
4541
4656
  });
4542
4657
 
4543
4658
  async function onContextAction(action: ContextAction, targets: FileNode[]) {
@@ -4607,6 +4722,32 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
4607
4722
  case 'download':
4608
4723
  await downloadSelection(targets);
4609
4724
  break;
4725
+ case 'archive-create':
4726
+ if (targets.length === 0) break;
4727
+ archiveTargets.value = targets;
4728
+ archiveInPane.value = actingInPane();
4729
+ archiveDestinationDir.value = archiveInPane.value
4730
+ ? qualify(splitPaneRef.value?.getPath() ?? '')
4731
+ : qualify(currentPath.value);
4732
+ archiveError.value = '';
4733
+ showArchiveCreate.value = true;
4734
+ break;
4735
+ case 'archive-extract':
4736
+ if (!targets[0] || !isArchiveFile(targets[0])) break;
4737
+ archiveTarget.value = targets[0];
4738
+ archiveInPane.value = actingInPane();
4739
+ archiveDestinationDir.value = wireParent(targets[0].path);
4740
+ archiveError.value = '';
4741
+ showArchiveExtract.value = true;
4742
+ break;
4743
+ case 'archive-extract-here':
4744
+ if (!targets[0] || !isArchiveFile(targets[0])) break;
4745
+ archiveTarget.value = targets[0];
4746
+ archiveInPane.value = actingInPane();
4747
+ archiveDestinationDir.value = wireParent(targets[0].path);
4748
+ archiveError.value = '';
4749
+ await startArchiveExtraction(targets[0], archiveDestinationDir.value, archiveInPane.value);
4750
+ break;
4610
4751
  /* tasi:m1 — the same dialog, twice; only `mode` differs. */
4611
4752
  case 'move-to':
4612
4753
  case 'copy-to':
@@ -4913,6 +5054,152 @@ async function onDocumentCreated(file: { path: string; name: string; ext: string
4913
5054
  void markRecent(node);
4914
5055
  }
4915
5056
 
5057
+ function archiveRequestCode(err: unknown): string | undefined {
5058
+ const detail = (err as { detail?: string })?.detail;
5059
+ if (!detail) return undefined;
5060
+ try {
5061
+ return (JSON.parse(detail) as { code?: string }).code;
5062
+ } catch {
5063
+ return undefined;
5064
+ }
5065
+ }
5066
+
5067
+ function archiveRequestError(err: unknown): string {
5068
+ const detail = (err as { detail?: string })?.detail;
5069
+ if (detail) {
5070
+ try {
5071
+ const body = JSON.parse(detail) as { code?: string; error?: string };
5072
+ const byCode: Record<string, string> = {
5073
+ PASSWORD_REQUIRED: 'archive.password_required',
5074
+ BAD_PASSWORD: 'archive.bad_password',
5075
+ TARGET_EXISTS: 'archive.target_exists',
5076
+ PASSWORD_CHARSET: 'archive.password_charset',
5077
+ UNSUPPORTED_FORMAT: 'archive.error_unsupported',
5078
+ ARCHIVE_LIMIT_EXCEEDED: 'archive.error_limit',
5079
+ PROVIDER_UNAVAILABLE: 'archive.error_unavailable',
5080
+ };
5081
+ if (body.code && byCode[body.code]) return t(byCode[body.code]);
5082
+ if (body.error) return body.error;
5083
+ } catch {
5084
+ // The common API wrapper still supplies a friendly status message.
5085
+ }
5086
+ }
5087
+ return (err as Error)?.message || t('toast.failed');
5088
+ }
5089
+
5090
+ async function submitArchiveCreate(value: {
5091
+ name: string;
5092
+ format: ArchiveCreateFormat;
5093
+ password?: string;
5094
+ encrypt_filenames: boolean;
5095
+ compression: number;
5096
+ solid?: boolean;
5097
+ dictionary_size_mb?: number;
5098
+ }) {
5099
+ archiveBusy.value = true;
5100
+ archiveError.value = '';
5101
+ try {
5102
+ const { op } = await api.archiveCreate({
5103
+ dest: wireJoin(archiveDestinationDir.value, value.name),
5104
+ sources: archiveTargets.value.map((node) => node.path),
5105
+ format: value.format,
5106
+ password: value.password,
5107
+ encrypt_filenames: value.encrypt_filenames,
5108
+ compression: value.compression,
5109
+ solid: value.solid,
5110
+ dictionary_size_mb: value.dictionary_size_mb,
5111
+ });
5112
+ pendingOps.register(op);
5113
+ showArchiveCreate.value = false;
5114
+ flashToast(t('archive.queued'));
5115
+ } catch (err) {
5116
+ archiveError.value = archiveRequestError(err);
5117
+ emit('error', { message: archiveError.value, context: { op: 'archive-create' } });
5118
+ } finally {
5119
+ archiveBusy.value = false;
5120
+ }
5121
+ }
5122
+
5123
+ async function startArchiveExtraction(
5124
+ target: FileNode,
5125
+ dest: string,
5126
+ inPane: boolean,
5127
+ password?: string,
5128
+ ) {
5129
+ archiveBusy.value = true;
5130
+ archiveError.value = '';
5131
+ try {
5132
+ const result = await api.archiveExtract(target.path, {
5133
+ dest,
5134
+ password,
5135
+ });
5136
+ showArchiveExtract.value = false;
5137
+ showArchivePassword.value = false;
5138
+ archivePasswordAction.value = null;
5139
+ archivePasswordError.value = '';
5140
+ if (result.op) {
5141
+ pendingOps.register(result.op);
5142
+ flashToast(t('archive.extraction_queued'));
5143
+ return;
5144
+ }
5145
+ if (inPane) await splitPaneRef.value?.reload();
5146
+ else await load();
5147
+ flashToast(t('archive.extracted', { count: result.count ?? 0 }));
5148
+ } catch (err) {
5149
+ const code = archiveRequestCode(err);
5150
+ if (code === 'PASSWORD_REQUIRED' || code === 'BAD_PASSWORD') {
5151
+ archivePasswordAction.value = { target, dest, inPane };
5152
+ archivePasswordError.value = code === 'BAD_PASSWORD' ? t('archive.bad_password') : '';
5153
+ showArchiveExtract.value = false;
5154
+ showArchivePassword.value = true;
5155
+ return;
5156
+ }
5157
+ const message = archiveRequestError(err);
5158
+ if (showArchivePassword.value) archivePasswordError.value = message;
5159
+ else if (showArchiveExtract.value) archiveError.value = message;
5160
+ /* "Extract here" opens no dialog, so an error written into one was never
5161
+ * seen: a refused archive (a link inside, too large, a format this server
5162
+ * cannot read) looked like a click that did nothing. */
5163
+ else showToast({ message }, 6000);
5164
+ } finally {
5165
+ archiveBusy.value = false;
5166
+ }
5167
+ }
5168
+
5169
+ async function submitArchiveExtract(value: { folder: string }) {
5170
+ const target = archiveTarget.value;
5171
+ if (!target) return;
5172
+ await startArchiveExtraction(
5173
+ target,
5174
+ wireJoin(archiveDestinationDir.value, value.folder),
5175
+ archiveInPane.value,
5176
+ );
5177
+ }
5178
+
5179
+ async function submitArchivePassword(password: string) {
5180
+ const action = archivePasswordAction.value;
5181
+ if (!action) return;
5182
+ archivePasswordError.value = '';
5183
+ await startArchiveExtraction(action.target, action.dest, action.inPane, password);
5184
+ }
5185
+
5186
+ function closeArchivePassword() {
5187
+ showArchivePassword.value = false;
5188
+ archivePasswordAction.value = null;
5189
+ archivePasswordError.value = '';
5190
+ }
5191
+
5192
+ async function cancelPendingOp(id: number) {
5193
+ try {
5194
+ await api.opsCancel(id);
5195
+ await pendingOps.poll();
5196
+ } catch (err) {
5197
+ const op = pendingOps.ops.value.find((candidate) => candidate.id === id);
5198
+ const archive = op?.op_type === 'archive-create' || op?.op_type === 'archive-extract';
5199
+ flashToast(archive ? archiveRequestError(err) : t('plugin.cancel_failed'));
5200
+ }
5201
+ }
5202
+
4916
5203
  async function submitNewFolder(name: string) {
4917
5204
  const inPane = mutationInPane.value; /* ui-fix — new folder in the side pane */
4918
5205
  try {
@@ -7271,6 +7558,26 @@ function closeRecoveryKey() {
7271
7558
  role="status"
7272
7559
  >{{ t('search.truncated') }}</div>
7273
7560
 
7561
+ <!-- The catalog does not cover all of this storage yet (a first sync, a
7562
+ lazily cataloged storage): search, folder sizes and usage leave part
7563
+ of it out, and this says so and why (lib/catalogCoverage). -->
7564
+ <div
7565
+ v-if="coverageShown"
7566
+ class="fe-coverage"
7567
+ role="status"
7568
+ data-testid="catalog-coverage"
7569
+ >
7570
+ <span class="fe-coverage__text">{{ t(coverageShown.key, coverageShown.vars) }}</span>
7571
+ <button
7572
+ v-if="coverageShown.offerCatalogAll"
7573
+ type="button"
7574
+ class="fe-coverage__action"
7575
+ data-testid="catalog-coverage-all"
7576
+ :disabled="catalogAllBusy"
7577
+ @click="catalogAll(coverageShown.storage)"
7578
+ >{{ t('coverage.catalog_all') }}</button>
7579
+ </div>
7580
+
7274
7581
  <!-- Live presence: who else is viewing this folder (empty → nothing shown).
7275
7582
  When the live socket is unavailable the same strip carries a small
7276
7583
  degraded-connection badge instead (presence is empty in fallback);
@@ -7854,8 +8161,8 @@ function closeRecoveryKey() {
7854
8161
  :center="opsCenter"
7855
8162
  :output-mode-of="pluginOutputModeOf"
7856
8163
  :caller-admin="callerAdmin"
8164
+ @cancel="cancelPendingOp"
7857
8165
  @dismiss="(id) => pendingOps.dismiss(id)"
7858
- @cancel="onCancelPendingOp"
7859
8166
  @open="onOpenOpOutput"
7860
8167
  />
7861
8168
 
@@ -7937,6 +8244,39 @@ function closeRecoveryKey() {
7937
8244
  @submit="submitNewFolder"
7938
8245
  @encrypted="showNewFolder = false; showEncFolder = true /* wiring:e2 */"
7939
8246
  />
8247
+ <ArchiveCreateModal
8248
+ :open="showArchiveCreate"
8249
+ :locale="locale"
8250
+ :count="archiveTargets.length"
8251
+ :suggested-name="archiveSuggestedName"
8252
+ :default-format="archiveDefaultFormat"
8253
+ :allowed-formats="archiveAllowedFormats"
8254
+ :encryption="archiveEncryption"
8255
+ :busy="archiveBusy"
8256
+ :request-error="archiveError"
8257
+ @close="showArchiveCreate = false"
8258
+ @submit="submitArchiveCreate"
8259
+ />
8260
+ <ArchiveExtractModal
8261
+ :open="showArchiveExtract"
8262
+ :locale="locale"
8263
+ :archive-name="archiveTarget?.basename || ''"
8264
+ :suggested-folder="archiveSuggestedFolder"
8265
+ :busy="archiveBusy"
8266
+ :error="archiveError"
8267
+ @close="showArchiveExtract = false"
8268
+ @submit="submitArchiveExtract"
8269
+ />
8270
+ <ArchivePasswordModal
8271
+ :open="showArchivePassword"
8272
+ :locale="locale"
8273
+ :archive-name="archivePasswordAction?.target.basename || archiveTarget?.basename || ''"
8274
+ :busy="archiveBusy"
8275
+ :error="archivePasswordError"
8276
+ @close="closeArchivePassword"
8277
+ @submit="submitArchivePassword"
8278
+ />
8279
+
7940
8280
  <!-- tasi:m1 — "Şuraya taşı…" / "Şuraya kopyala…". ONE dialog for both, and
7941
8281
  the same one anything else that has to ask for a folder mounts (the
7942
8282
  new-document flow does): a second private folder browser is how two
@@ -154,10 +154,27 @@ async function show(ev: { clientX: number; clientY: number }, nodes: FileNode[])
154
154
  function clampToViewport() {
155
155
  const el = menuEl.value;
156
156
  if (!el || typeof window === 'undefined') return;
157
+ /* Measure the natural height: drop a cap an earlier opening left behind. */
158
+ el.style.maxHeight = '';
159
+ el.style.overflowY = '';
157
160
  const rect = el.getBoundingClientRect();
158
161
  const margin = 8;
159
162
  const vw = window.innerWidth;
160
163
  const vh = window.innerHeight;
164
+ /* ⚠⚠ A menu taller than the window scrolls inside itself. Neither a flip nor
165
+ * a clamp can fit it, and without a height cap the items past the bottom edge
166
+ * are simply unreachable: installing a few apps (each adds its actions) put
167
+ * "Lock probe" below a 720px window and nothing could click it (0.44.0's gate
168
+ * run, spec 95, 2026-09-25). Set on the element here rather than in the
169
+ * template's :style, whose line the RTL audit allow-lists word for word. */
170
+ const room = vh - 2 * margin;
171
+ if (rect.height > room) {
172
+ el.style.maxHeight = room + 'px';
173
+ el.style.overflowY = 'auto';
174
+ y.value = margin;
175
+ x.value = openAlongInline(anchorX, rect.width, vw, dir.value, margin);
176
+ return;
177
+ }
161
178
  /* wiring:c4 — edge flip: overflowing the bottom/right edge re-anchors the
162
179
  * menu above/left of the cursor when there's room; clamping stays as the
163
180
  * fallback for tiny viewports. */
@@ -25,6 +25,7 @@ import {
25
25
  type FilePreview,
26
26
  } from '../lib/filePreview'; /* gorunum:v1-preview */
27
27
  import StarButton from './StarButton.vue';
28
+ import ThumbTile from './ThumbTile.vue';
28
29
  import { applyDragGhost } from '../lib/dragGhost';
29
30
  import { parentDirOf } from '../lib/listing'; /* the folder a row sits in — one split rule for every view */
30
31
  import {
@@ -358,41 +359,34 @@ function metaFor(n: FileNode): string {
358
359
  v-html="fileIconTile(n)"
359
360
  ></span>
360
361
  </template>
361
- <!-- draggable="false" for the same reason as GridView: HTML5 image
362
- drag adds a 'Files' MIME to dataTransfer, which would trip the
363
- parent's upload handler on internal drags. -->
364
- <img
365
- v-else-if="thumbOf(n)"
366
- :src="thumbOf(n)!"
362
+ <!-- ⚠⚠ Through ThumbTile, as in GridView: read in this template, each
363
+ thumbnail that arrived re-rendered every tile of the folder. The
364
+ tile draws the <img> (draggable="false", same reason as GridView)
365
+ and the play badge over a video frame; its slot until then. -->
366
+ <ThumbTile
367
+ v-else
368
+ :node="n"
369
+ :src-of="thumbOf"
370
+ :video-badge="drawsAsVideo(n)"
367
371
  :alt="n.basename"
368
372
  :class="{ 'fe-thumb--page': drawsAsPage(n) /* gorunum:v1-preview — crop a page from its TOP */ }"
369
- loading="lazy"
370
- draggable="false"
371
- />
372
- <!-- ikon:emoji — see GridView: the folder shape survives, the padlock
373
- is cut out of it, and the name the 🔒 never had is on the span. -->
374
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
375
- <span
376
- v-else-if="isEncryptedFolder(n)"
377
- class="fe-gal__icon fe-gal__icon--svg"
378
- role="img"
379
- :aria-label="t('e2e.badge')"
380
- v-html="encryptedFolderTile()"
381
- ></span>
382
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
383
- <span v-else class="fe-gal__icon fe-gal__icon--svg" v-html="fileIconTile(n)"></span>
373
+ >
374
+ <!-- ikon:emoji — see GridView: the folder shape survives, the padlock
375
+ is cut out of it, and the name the 🔒 never had is on the span. -->
376
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
377
+ <span
378
+ v-if="isEncryptedFolder(n)"
379
+ class="fe-gal__icon fe-gal__icon--svg"
380
+ role="img"
381
+ :aria-label="t('e2e.badge')"
382
+ v-html="encryptedFolderTile()"
383
+ ></span>
384
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
385
+ <span v-else class="fe-gal__icon fe-gal__icon--svg" v-html="fileIconTile(n)"></span>
386
+ </ThumbTile>
384
387
  <!-- Same star chip as the grid, same component, same rule: painted
385
388
  when starred, on hover/focus otherwise. It sits above .fe-gal__meta
386
389
  (which is aria-hidden and covers the tile's foot on hover). -->
387
- <!-- gorunum:v1-preview — a frame lifted out of a video is, on a card,
388
- indistinguishable from a photograph. The badge is the difference,
389
- and it is drawn only over a real frame: a video that fell back to
390
- its type tile already says what it is. -->
391
- <span
392
- v-if="!previewKind(n) && thumbOf(n) && drawsAsVideo(n)"
393
- class="fe-thumb__play"
394
- aria-hidden="true"
395
- ></span>
396
390
  <!-- issue #26 — the card's checkbox, the one click that selects: the
397
391
  corner opposite the star, on the same reveal rule plus "anything
398
392
  is selected" (styles/base.css .fe-item-check). -->