@brftech/filex-core 0.43.2 → 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 (55) 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-I0AceHh8.js → CsvViewer-B95fArq0.js} +2 -2
  4. package/dist/{CsvViewer-I0AceHh8.js.map → CsvViewer-B95fArq0.js.map} +1 -1
  5. package/dist/{DrawioViewer-B2vl10hY.js → DrawioViewer-BAaPMTge.js} +2 -2
  6. package/dist/{DrawioViewer-B2vl10hY.js.map → DrawioViewer-BAaPMTge.js.map} +1 -1
  7. package/dist/{EpubViewer-L3rxrx5i.js → EpubViewer-B-B7bRW7.js} +2 -2
  8. package/dist/{EpubViewer-L3rxrx5i.js.map → EpubViewer-B-B7bRW7.js.map} +1 -1
  9. package/dist/{IpynbViewer-Do8n0RzJ.js → IpynbViewer-COOTyOcu.js} +2 -2
  10. package/dist/{IpynbViewer-Do8n0RzJ.js.map → IpynbViewer-COOTyOcu.js.map} +1 -1
  11. package/dist/{MermaidViewer-0JqH6k0f.js → MermaidViewer-CjVCmR3s.js} +2 -2
  12. package/dist/{MermaidViewer-0JqH6k0f.js.map → MermaidViewer-CjVCmR3s.js.map} +1 -1
  13. package/dist/{PsdViewer-BnI-NDr8.js → PsdViewer-xsIYL9Gu.js} +2 -2
  14. package/dist/{PsdViewer-BnI-NDr8.js.map → PsdViewer-xsIYL9Gu.js.map} +1 -1
  15. package/dist/{TiffViewer-CrLDXI_f.js → TiffViewer-CtwnTxqL.js} +2 -2
  16. package/dist/{TiffViewer-CrLDXI_f.js.map → TiffViewer-CtwnTxqL.js.map} +1 -1
  17. package/dist/{Viewer3D-CpB1K0-0.js → Viewer3D-xYMwndEy.js} +2 -2
  18. package/dist/{Viewer3D-CpB1K0-0.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-D9_EBsqF.js → index-BBToAjwL.js} +16563 -15727
  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/HomeView.vue +14 -4
  30. package/src/components/InspectorPanel.vue +9 -3
  31. package/src/components/ListView.vue +6 -3
  32. package/src/components/OperationsCenter.vue +6 -0
  33. package/src/components/PendingOpsTray.vue +5 -3
  34. package/src/composables/useFileApi.ts +36 -6
  35. package/src/composables/useLocale.ts +26 -0
  36. package/src/composables/useOperations.ts +7 -1
  37. package/src/composables/usePendingOps.ts +11 -7
  38. package/src/index.ts +15 -0
  39. package/src/lib/actionIcons.ts +6 -0
  40. package/src/lib/archiveFormats.ts +12 -0
  41. package/src/lib/archivePreviewCache.ts +61 -0
  42. package/src/lib/catalogCoverage.ts +137 -0
  43. package/src/locales/en.ts +67 -0
  44. package/src/locales/tr.ts +65 -0
  45. package/src/modals/ArchiveCreateModal.vue +192 -0
  46. package/src/modals/ArchiveExtractModal.vue +57 -0
  47. package/src/modals/ArchivePasswordModal.vue +62 -0
  48. package/src/modals/PreviewModal.vue +15 -1
  49. package/src/styles/base.css +47 -2
  50. package/src/types/ExplorerConfig.ts +9 -0
  51. package/src/types/FileNode.ts +19 -0
  52. package/src/viewers/ArchiveViewer.vue +145 -40
  53. package/dist/ArchiveViewer-Bn-pwgX8.js +0 -149
  54. package/dist/ArchiveViewer-Bn-pwgX8.js.map +0 -1
  55. package/dist/index-D9_EBsqF.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.43.2",
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. */
@@ -46,6 +46,9 @@ export interface HomeStorage {
46
46
  readOnly?: boolean;
47
47
  /** Bytes this storage holds, when the server reported one. */
48
48
  usedBytes?: number;
49
+ /** The figure counts only part of the storage: its catalog does not cover
50
+ * all of it yet (lib/catalogCoverage). Drawn as a lower bound. */
51
+ usedPartial?: boolean;
49
52
  }
50
53
 
51
54
  const props = defineProps<{
@@ -145,9 +148,12 @@ const storageTile = fileIconTile({ type: 'dir', mime_type: 'inode/storage' });
145
148
  * with the wrong size line says something false.
146
149
  */
147
150
  function storageCaption(s: HomeStorage): string {
148
- return typeof s.usedBytes === 'number' && s.usedBytes >= 0
149
- ? t('drive.storage.used_unlimited', { used: formatSize(s.usedBytes) })
150
- : t('conn.guide.storage');
151
+ if (typeof s.usedBytes !== 'number' || s.usedBytes < 0) return t('conn.guide.storage');
152
+ // ⚠ A figure the catalog does not stand behind in full is a lower bound —
153
+ // "at least 1.2 GB used" — the same rule the listing's folder sizes follow.
154
+ return s.usedPartial
155
+ ? t('drive.storage.used_partial', { used: formatSize(s.usedBytes) })
156
+ : t('drive.storage.used_unlimited', { used: formatSize(s.usedBytes) });
151
157
  }
152
158
 
153
159
  /**
@@ -199,7 +205,11 @@ function openNode(n: FileNode) {
199
205
  <span class="fe-home__storage-tile" aria-hidden="true" v-html="storageTile"></span>
200
206
  <span class="fe-home__storage-main">
201
207
  <span class="fe-home__storage-label">{{ s.label || s.name }}</span>
202
- <span class="fe-home__storage-meta">{{ storageCaption(s) }}</span>
208
+ <span
209
+ class="fe-home__storage-meta"
210
+ data-testid="home-storage-used"
211
+ :data-partial="s.usedPartial ? 'true' : undefined"
212
+ >{{ storageCaption(s) }}</span>
203
213
  <!-- A read-only mount says so on its card too (issue #30) — on a
204
214
  line of its own, the panel's own mark (StorageTags, the one
205
215
  read-only tag of every surface). ⚠ It used to be glued to
@@ -182,7 +182,7 @@ const emit = defineEmits<{
182
182
  (e: 'plugin-open', payload: { plugin: string; req: { path: string; action?: string; view?: string } }): void;
183
183
  }>();
184
184
 
185
- const { t, formatSize, formatDate: formatDateOf, nodeDisplayName } = useLocale(
185
+ const { t, formatSize, formatNodeSize, nodeSizeHint, formatDate: formatDateOf, nodeDisplayName } = useLocale(
186
186
  () => props.locale,
187
187
  );
188
188
 
@@ -209,6 +209,12 @@ const nodeId = computed<number | null>(() =>
209
209
  const multiTotal = computed(() =>
210
210
  props.nodes.reduce((acc, n) => acc + (typeof n.size === 'number' ? n.size : 0), 0),
211
211
  );
212
+ /** The selection's total, as a size: a lower bound when any selected folder's
213
+ * own size is one (`size_partial`, drawn by useLocale formatNodeSize). */
214
+ const multiSize = computed(() => ({
215
+ size: multiTotal.value,
216
+ size_partial: props.nodes.some((n) => n.size_partial === true),
217
+ }));
212
218
  const etag = computed<string | null>(() => {
213
219
  const v = single.value?.etag;
214
220
  return typeof v === 'string' && v !== '' ? v : null;
@@ -249,7 +255,7 @@ const headName = computed<string>(() => {
249
255
 
250
256
  /** The line under it — "Folder", "TypeScript · 4.8 KB", "12.4 MB in total". */
251
257
  const headCaption = computed<string>(() => {
252
- if (isMulti.value) return formatSize(multiTotal.value);
258
+ if (isMulti.value) return formatNodeSize(multiSize.value);
253
259
  const n = single.value;
254
260
  if (n) {
255
261
  const kind = typeLabelFor(n, t);
@@ -798,7 +804,7 @@ watch(
798
804
  <dl v-if="isMulti" class="fe-inspector__meta">
799
805
  <div class="fe-inspector__row">
800
806
  <dt>{{ t('inspector.size') }}</dt>
801
- <dd>{{ formatSize(multiTotal) }}</dd>
807
+ <dd :title="nodeSizeHint(multiSize)">{{ formatNodeSize(multiSize) }}</dd>
802
808
  </div>
803
809
  </dl>
804
810
 
@@ -177,7 +177,8 @@ const emit = defineEmits<{
177
177
 
178
178
  const {
179
179
  t,
180
- formatSize,
180
+ formatNodeSize,
181
+ nodeSizeHint,
181
182
  formatDate,
182
183
  formatDateFull,
183
184
  formatMonthYear,
@@ -568,8 +569,10 @@ const columns = computed<DataColumn<FileNode>[]>(() => [
568
569
  label: t('col.size'),
569
570
  sortable: true,
570
571
  class: 'fe-list__col--size',
571
- format: (n) => formatSize(n.size),
572
- title: () => undefined,
572
+ // A folder the catalog does not cover in full says so: "≥ 1.2 GB", or
573
+ // "—" when nothing below it is known (useLocale formatNodeSize).
574
+ format: (n) => formatNodeSize(n),
575
+ title: (n) => nodeSizeHint(n),
573
576
  },
574
577
  { id: 'star', label: t('col.star'), class: 'fe-list__col--star', headerLabel: false },
575
578
  ]);