@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
@@ -38,6 +38,7 @@ import {
38
38
  } from '../lib/dateGroups'; /* gruplama */
39
39
  import { useSortStore, type ListingOrder } from '../lib/sortOrder'; /* gruplama */
40
40
  import StarButton from './StarButton.vue';
41
+ import ThumbTile from './ThumbTile.vue';
41
42
  import { snippetSegments } from '../lib/snippet'; /* bul:s3 */
42
43
  import { applyDragGhost } from '../lib/dragGhost'; /* wiring:c4 */
43
44
  import { contentDir } from '../lib/direction';
@@ -229,7 +230,8 @@ function headingBefore(n: FileNode): string | null {
229
230
 
230
231
 
231
232
  // Prefer the authenticated resolver when the host wired one; otherwise fall
232
- // back to the raw URL (legacy same-origin behavior).
233
+ // back to the raw URL (legacy same-origin behavior). ⚠ Called by ThumbTile,
234
+ // not by this template — see the note on the tile in the markup.
233
235
  function thumbOf(n: FileNode): string | null {
234
236
  return props.thumbSrc ? props.thumbSrc(n) : (n.thumb_url ?? null);
235
237
  }
@@ -512,48 +514,43 @@ function snippetTitle(snippet: string): string {
512
514
  v-html="fileIconTile(n)"
513
515
  ></span>
514
516
  </template>
515
- <!--
516
- draggable="false" — HTML5 image drag dataTransfer adds 'Files'
517
- MIME, which trips the parent's upload handler; without this
518
- the user dragging an item with a thumbnail re-uploads it.
519
- Parent card stays draggable=true so internal move works.
520
- -->
521
- <img
522
- v-else-if="thumbOf(n)"
523
- :src="thumbOf(n)!"
517
+ <!-- ⚠⚠ The thumbnail is read inside ThumbTile, never here: read in
518
+ this template, each thumbnail that arrived re-rendered every card
519
+ of the folder (see ThumbTile). The tile draws the <img> once there
520
+ is a picture — draggable="false", because an image drag puts a
521
+ 'Files' MIME on the dataTransfer and the parent's upload handler
522
+ re-uploads the file being moved — and its slot until then.
523
+ gorunum:v1-preview — the play badge goes over a real video frame
524
+ only: a video that fell back to its type tile already says what
525
+ it is. -->
526
+ <ThumbTile
527
+ v-else
528
+ :node="n"
529
+ :src-of="thumbOf"
530
+ :video-badge="drawsAsVideo(n)"
524
531
  :alt="n.basename"
525
532
  :class="{ 'fe-thumb--page': drawsAsPage(n) /* gorunum:v1-preview — crop a page from its TOP */ }"
526
- loading="lazy"
527
- draggable="false"
528
- />
529
- <!-- ikon:emoji — an encrypted folder is still a FOLDER, so it keeps the
530
- folder's own shape and colour with the padlock cut out of it; the
531
- 🔒 it replaces said "locked" and nothing else. One definition,
532
- in lib/fileIcons, for all three views. -->
533
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
534
- <span
535
- v-else-if="isEncryptedFolder(n)"
536
- class="fe-grid__icon fe-grid__icon--svg"
537
- role="img"
538
- :aria-label="t('e2e.badge')"
539
- v-html="encryptedFolderTile()"
540
- ></span>
541
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
542
- <span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconTile(n)"></span>
533
+ >
534
+ <!-- ikon:emoji — an encrypted folder is still a FOLDER, so it keeps the
535
+ folder's own shape and colour with the padlock cut out of it; the
536
+ 🔒 it replaces said "locked" and nothing else. One definition,
537
+ in lib/fileIcons, for all three views. -->
538
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
539
+ <span
540
+ v-if="isEncryptedFolder(n)"
541
+ class="fe-grid__icon fe-grid__icon--svg"
542
+ role="img"
543
+ :aria-label="t('e2e.badge')"
544
+ v-html="encryptedFolderTile()"
545
+ ></span>
546
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
547
+ <span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconTile(n)"></span>
548
+ </ThumbTile>
543
549
  <!-- Star, ON the tile. A hover-only affordance would be invisible to
544
550
  the person looking for what they starred, so the chip is always
545
551
  painted once the file IS starred and only appears on hover/focus
546
552
  otherwise (see .fe-grid__star in styles/base.css). @click.stop so
547
553
  starring never doubles as opening the card. -->
548
- <!-- gorunum:v1-preview — a frame lifted out of a video is, on a card,
549
- indistinguishable from a photograph. The badge is the difference,
550
- and it is drawn only over a real frame: a video that fell back to
551
- its type tile already says what it is. -->
552
- <span
553
- v-if="!previewKind(n) && thumbOf(n) && drawsAsVideo(n)"
554
- class="fe-thumb__play"
555
- aria-hidden="true"
556
- ></span>
557
554
  <div v-if="canStar(n)" class="fe-grid__star" @click.stop @dblclick.stop>
558
555
  <StarButton
559
556
  :starred="!!starredIds?.has(n.id!)"
@@ -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
 
@@ -65,6 +65,7 @@ import {
65
65
  } from '../lib/viewPrefs'; /* tablo:t1 — per-folder columns */
66
66
  import DataTable, { type DataColumn } from './DataTable.vue';
67
67
  import StarButton from './StarButton.vue';
68
+ import ThumbTile from './ThumbTile.vue';
68
69
 
69
70
  const props = defineProps<{
70
71
  files: FileNode[];
@@ -176,7 +177,8 @@ const emit = defineEmits<{
176
177
 
177
178
  const {
178
179
  t,
179
- formatSize,
180
+ formatNodeSize,
181
+ nodeSizeHint,
180
182
  formatDate,
181
183
  formatDateFull,
182
184
  formatMonthYear,
@@ -567,8 +569,10 @@ const columns = computed<DataColumn<FileNode>[]>(() => [
567
569
  label: t('col.size'),
568
570
  sortable: true,
569
571
  class: 'fe-list__col--size',
570
- format: (n) => formatSize(n.size),
571
- 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),
572
576
  },
573
577
  { id: 'star', label: t('col.star'), class: 'fe-list__col--star', headerLabel: false },
574
578
  ]);
@@ -786,21 +790,24 @@ const tableGroups = computed(() =>
786
790
  v-html="encryptedFolderTile()"
787
791
  ></span>
788
792
  <!-- tablo:t1 — the real thumbnail, at the tile's own size so the row
789
- height does not move. `draggable="false"`: dragging an <img> puts a
790
- 'Files' MIME on the dataTransfer and the parent's upload handler
791
- re-uploads the thing you were only moving. -->
792
- <img
793
- v-else-if="thumbOf(row)"
793
+ height does not move. ⚠⚠ Through ThumbTile, never read in this
794
+ template: here, each thumbnail that arrived re-rendered every row of
795
+ the table. The tile draws the <img> (`draggable="false"`: dragging
796
+ an <img> puts a 'Files' MIME on the dataTransfer and the parent's
797
+ upload handler re-uploads the thing you were only moving) and the
798
+ type tile until then. -->
799
+ <ThumbTile
800
+ v-else
801
+ :node="row"
802
+ :src-of="thumbOf"
794
803
  class="fe-list__icon fe-list__icon--img"
795
804
  :class="{ 'fe-thumb--page': drawsAsPage(row) }"
796
- :src="thumbOf(row)!"
797
805
  alt=""
798
806
  aria-hidden="true"
799
- loading="lazy"
800
- draggable="false"
801
- />
802
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
803
- <span v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconTile(row)"></span>
807
+ >
808
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
809
+ <span class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconTile(row)"></span>
810
+ </ThumbTile>
804
811
  <div class="fe-list__name-wrap">
805
812
  <span class="fe-list__name" :title="nameTitle(row)">
806
813
  <!-- ⚠ RTL: `<bdi>` — the name is the person's text, not the
@@ -70,6 +70,8 @@ const KIND_ICONS: Record<Operation['kind'], string> = {
70
70
  /* An app-plugin job — the puzzle piece its menu row wears (lib/actionIcons). */
71
71
  plugin:
72
72
  'M9.5 4.5a2 2 0 1 1 4 0h3a1.5 1.5 0 0 1 1.5 1.5v3a2 2 0 1 1 0 4v3a1.5 1.5 0 0 1-1.5 1.5h-3a2 2 0 1 1-4 0h-3A1.5 1.5 0 0 1 5 16v-3a2 2 0 1 1 0-4V6a1.5 1.5 0 0 1 1.5-1.5z',
73
+ 'archive-create': 'M4 8V5h16v3zM5 8h14v12H5zM10 12h4',
74
+ 'archive-extract': 'M4 8V5h16v3zM5 8h14v12H5zM10 12h4',
73
75
  };
74
76
 
75
77
  function kindLabel(o: Operation): string {
@@ -100,6 +102,7 @@ function outputModeTitle(o: Operation): string {
100
102
 
101
103
  function statusText(o: Operation): string {
102
104
  if (o.status === 'running') {
105
+ if (o.cancelling) return t('opc.cancelling');
103
106
  if (o.queued) return t('opc.queued');
104
107
  // A plugin job says what it is doing in its own words (`job_progress`
105
108
  // message); that beats a bare count.
@@ -107,6 +110,9 @@ function statusText(o: Operation): string {
107
110
  if (o.totalBytes && o.totalBytes > 0) {
108
111
  return `${formatSize(o.uploadedBytes ?? 0)} / ${formatSize(o.totalBytes)}`;
109
112
  }
113
+ if ((o.kind === 'archive-create' || o.kind === 'archive-extract') && o.percent !== null) {
114
+ return t('opc.percent', { n: o.percent });
115
+ }
110
116
  if (o.totalCount && o.totalCount > 0) {
111
117
  return `${o.doneCount ?? 0}/${o.totalCount}`;
112
118
  }
@@ -56,12 +56,12 @@ const emit = defineEmits<{
56
56
 
57
57
  function mapStatus(op: PendingOp): OperationStatus {
58
58
  if (op.status === 'done') return 'done';
59
- if (op.status === 'error') return 'error';
60
59
  if (op.status === 'cancelled') return 'aborted';
60
+ if (op.status === 'error') return 'error';
61
61
  return 'running'; // pending | running
62
62
  }
63
63
 
64
- const DRAWN_KINDS: ReadonlySet<string> = new Set(['copy', 'move', 'delete', 'plugin']);
64
+ const DRAWN_KINDS: ReadonlySet<string> = new Set(['copy', 'move', 'delete', 'plugin', 'archive-create', 'archive-extract']);
65
65
 
66
66
  /** A queue kind the center can draw. Anything it has no glyph for is shown as
67
67
  * a generic job (`plugin`), never folded into delete. */
@@ -79,6 +79,7 @@ function isPluginJob(op: PendingOp): boolean {
79
79
  * starts — for an administrator. */
80
80
  function mayCancel(op: PendingOp): boolean {
81
81
  if (op.status !== 'pending' && op.status !== 'running') return false;
82
+ if (op.op_type === 'archive-create' || op.op_type === 'archive-extract') return op.cancellable;
82
83
  if (isPluginJob(op)) return true;
83
84
  return op.op_type === 'trash-empty' && props.callerAdmin === true;
84
85
  }
@@ -103,6 +104,7 @@ watch(
103
104
  error: op.status === 'error' ? failureOf(op).text : null,
104
105
  errorDetail: op.status === 'error' ? (failureOf(op).detail ?? null) : null,
105
106
  queued: op.status === 'pending',
107
+ cancelling: op.status === 'cancelling',
106
108
  doneCount: op.progress_done,
107
109
  totalCount: op.progress_total,
108
110
  cancellable: mayCancel(op),
@@ -112,8 +114,8 @@ watch(
112
114
  outputMode: (isPluginJob(op) ? props.outputModeOf?.(op) : undefined) ?? null,
113
115
  },
114
116
  actions: {
115
- dismiss: () => emit('dismiss', op.id),
116
117
  cancel: () => emit('cancel', op.id),
118
+ dismiss: () => emit('dismiss', op.id),
117
119
  open: (path: string) => emit('open', op.id, path),
118
120
  },
119
121
  })),
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ThumbTile — one file's thumbnail, or whatever the view draws instead.
4
+ *
5
+ * ⚠⚠ Why a component. A thumbnail's object URL arrives later, and whatever
6
+ * read it is woken when it does. Read in a folder view's own template, that was
7
+ * the whole view: every arrival re-rendered every row (see useThumbs). Read
8
+ * here, it is this tile.
9
+ *
10
+ * It also asks late. The resolver starts the fetch, and it is only called once
11
+ * the tile is near the viewport (lib/nearViewport), so opening a folder no
12
+ * longer downloads the thumbnails of tiles nobody has scrolled to.
13
+ *
14
+ * The <img> takes the attributes the view gives the tile (class, alt, aria-*);
15
+ * the default slot is the fallback — the view's type tile — drawn until the
16
+ * picture is there, and for good when there is none.
17
+ *
18
+ * ⚠ No `loading="lazy"`: the picture is already only handed over near the
19
+ * viewport, and it is an object URL in memory, so lazy would defer nothing but
20
+ * the decode — and an <img> that has not loaded yet when useThumbs revokes its
21
+ * URL (its oldest, past the cache's cap) would come up broken.
22
+ */
23
+ import { computed, getCurrentInstance, onBeforeUnmount, onMounted, ref } from 'vue';
24
+ import type { FileNode } from '../types/FileNode';
25
+ import { whenNearViewport } from '../lib/nearViewport';
26
+
27
+ defineOptions({ inheritAttrs: false });
28
+
29
+ const props = defineProps<{
30
+ node: FileNode;
31
+ /** The view's resolver: the object URL to draw, or null (not yet, or none). */
32
+ srcOf: (n: FileNode) => string | null;
33
+ /** Draw the play badge over the picture — a frame lifted out of a video is,
34
+ * on a card, indistinguishable from a photograph. */
35
+ videoBadge?: boolean;
36
+ }>();
37
+
38
+ const near = ref(false);
39
+ let stopWaiting: (() => void) | null = null;
40
+
41
+ onMounted(() => {
42
+ /* The tile draws no box of its own (it is the picture, or the fallback), so
43
+ it watches the box it sits in — the view's thumbnail cell. */
44
+ const anchor = getCurrentInstance()?.subTree.el as Node | null | undefined;
45
+ stopWaiting = whenNearViewport(anchor?.parentElement, () => {
46
+ near.value = true;
47
+ });
48
+ });
49
+ onBeforeUnmount(() => stopWaiting?.());
50
+
51
+ const src = computed(() => (near.value ? props.srcOf(props.node) : null));
52
+ </script>
53
+
54
+ <template>
55
+ <img v-if="src" v-bind="$attrs" :src="src" draggable="false" />
56
+ <slot v-else />
57
+ <span v-if="src && videoBadge" class="fe-thumb__play" aria-hidden="true"></span>
58
+ </template>
@@ -21,6 +21,7 @@
21
21
  * thinks about CSRF vs Bearer vs Basic — it just calls `index(path)`.
22
22
  */
23
23
 
24
+ import type { StorageInfo } from '../lib/catalogCoverage';
24
25
  import type { ExplorerConfig, AuthConfig, EndpointMap } from '../types/ExplorerConfig';
25
26
  import { resolveLocale } from '../locales/resolve';
26
27
  import { listingAddress } from '../lib/internalPaths';
@@ -36,6 +37,7 @@ import type {
36
37
  UploadLimits,
37
38
  Capabilities,
38
39
  ArchiveEntry,
40
+ ArchiveCreateFormat,
39
41
  TrashEntry,
40
42
  } from '../types/FileNode';
41
43
  import type {
@@ -49,14 +51,15 @@ import type {
49
51
  /** Server-side PendingOp DTO (mirror of Modules\FishApp\Models\PendingOp::toApiArray). */
50
52
  export interface PendingOpDto {
51
53
  id: number;
52
- op_type: 'copy' | 'move' | 'delete';
53
- status: 'pending' | 'running' | 'done' | 'error';
54
+ op_type: 'copy' | 'move' | 'delete' | 'archive-create' | 'archive-extract';
55
+ status: 'pending' | 'running' | 'cancelling' | 'done' | 'error' | 'cancelled';
54
56
  progress_total: number;
55
57
  progress_done: number;
56
58
  target_path: string | null;
57
59
  source_dir: string | null;
58
60
  source_count: number;
59
61
  error_message: string | null;
62
+ cancellable: boolean;
60
63
  started_at: string | null;
61
64
  finished_at: string | null;
62
65
  created_at: string | null;
@@ -94,6 +97,10 @@ export interface ManagerResponse {
94
97
  * list is not the whole answer. Absent on other actions and on servers
95
98
  * older than the flag. */
96
99
  truncated?: boolean;
100
+ /** Every storage the caller can open, with how much of it the catalog
101
+ * covers (`coverage`, absent when all of it — lib/catalogCoverage). Absent
102
+ * on servers older than the lazy catalog. */
103
+ storage_info?: StorageInfo[];
97
104
  }
98
105
 
99
106
  /** A single ACL grant row (RBAC permissions panel). */
@@ -256,6 +263,7 @@ export function resolveEndpoints(config: ExplorerConfig): EndpointMap {
256
263
  capabilities: derive(config.capabilities, '/api/files/capabilities'),
257
264
  archiveList: derive(config.archiveList, '/api/files/archive/list'),
258
265
  archiveExtract: derive(config.archiveExtract, '/api/files/archive/extract'),
266
+ archiveCreate: derive(config.archiveCreate, '/api/files/archive/create'),
259
267
  archiveAdd: derive(config.archiveAdd, '/api/files/archive/add'),
260
268
  copy: derive(config.copy, '/api/files/copy'),
261
269
  moveAsync: derive(config.moveAsync, '/api/files/move'),
@@ -979,14 +987,14 @@ export function useFileApi(config: ExplorerConfig) {
979
987
  return jsonFetch(url, { method: 'DELETE' });
980
988
  }
981
989
 
982
- async function archiveList(path: string): Promise<{ entries: ArchiveEntry[] }> {
990
+ async function archiveList(path: string, password?: string): Promise<{ entries: ArchiveEntry[] }> {
983
991
  if (!endpoints.archiveList) throw new Error('archiveList endpoint not configured');
984
992
  const raw = await jsonFetch<{ entries: Array<{ name: string; size: number; is_dir: boolean; mtime?: number }> }>(
985
993
  endpoints.archiveList,
986
994
  {
987
995
  method: 'POST',
988
996
  headers: { 'Content-Type': 'application/json' },
989
- body: JSON.stringify({ path }),
997
+ body: JSON.stringify({ path, password }),
990
998
  },
991
999
  );
992
1000
  return {
@@ -999,12 +1007,33 @@ export function useFileApi(config: ExplorerConfig) {
999
1007
  };
1000
1008
  }
1001
1009
 
1002
- async function archiveExtract(path: string, members?: string[]): Promise<{ keys: string[]; count: number }> {
1010
+ async function archiveExtract(
1011
+ path: string,
1012
+ options: { members?: string[]; password?: string; dest?: string } = {},
1013
+ ): Promise<{ op?: PendingOpDto; keys?: string[]; count?: number }> {
1003
1014
  if (!endpoints.archiveExtract) throw new Error('archiveExtract endpoint not configured');
1004
1015
  return jsonFetch(endpoints.archiveExtract, {
1005
1016
  method: 'POST',
1006
1017
  headers: { 'Content-Type': 'application/json' },
1007
- body: JSON.stringify({ path, members }),
1018
+ body: JSON.stringify({ path, ...options }),
1019
+ });
1020
+ }
1021
+
1022
+ async function archiveCreate(payload: {
1023
+ dest: string;
1024
+ sources: string[];
1025
+ format: ArchiveCreateFormat;
1026
+ password?: string;
1027
+ encrypt_filenames?: boolean;
1028
+ compression?: number;
1029
+ solid?: boolean;
1030
+ dictionary_size_mb?: number;
1031
+ }): Promise<{ op: PendingOpDto }> {
1032
+ if (!endpoints.archiveCreate) throw new Error('archiveCreate endpoint not configured');
1033
+ return jsonFetch(endpoints.archiveCreate, {
1034
+ method: 'POST',
1035
+ headers: { 'Content-Type': 'application/json' },
1036
+ body: JSON.stringify(payload),
1008
1037
  });
1009
1038
  }
1010
1039
 
@@ -1147,6 +1176,7 @@ export function useFileApi(config: ExplorerConfig) {
1147
1176
  revokeShare,
1148
1177
  archiveList,
1149
1178
  archiveExtract,
1179
+ archiveCreate,
1150
1180
  archiveAdd,
1151
1181
  // Version history (koru:k1 inspector)
1152
1182
  listVersions,
@@ -536,6 +536,30 @@ export function useLocale(
536
536
  });
537
537
  }
538
538
 
539
+ /**
540
+ * A listed item's size, the way every view draws it (list, inspector,
541
+ * multi-selection total).
542
+ *
543
+ * ⚠ `size_partial` is the server saying the catalog does not cover all of a
544
+ * folder yet (docs/LAZY-CATALOGUE.md): a lazily cataloged folder that is not
545
+ * cataloged below, or any folder while its storage's first sync runs. Its
546
+ * size is then a lower bound — "≥ 1.2 GB" — or, when nothing below it is
547
+ * known, not a number at all ("—"): printing "0 B" for a folder full of
548
+ * files nobody has cataloged yet would be a fact that is false. One helper,
549
+ * so the list and the inspector cannot disagree about it.
550
+ */
551
+ function formatNodeSize(n: { size?: number | null; size_partial?: boolean } | null | undefined): string {
552
+ if (!n) return formatSize(null);
553
+ if (!n.size_partial) return formatSize(n.size);
554
+ return typeof n.size === 'number' && n.size > 0 ? t('size.at_least', { size: formatSize(n.size) }) : '—';
555
+ }
556
+
557
+ /** The hover text of a size `formatNodeSize` drew as partial, or undefined. */
558
+ function nodeSizeHint(n: { size?: number | null; size_partial?: boolean } | null | undefined): string | undefined {
559
+ if (!n?.size_partial) return undefined;
560
+ return typeof n.size === 'number' && n.size > 0 ? t('size.partial_hint') : t('size.unknown_hint');
561
+ }
562
+
539
563
  /**
540
564
  * Render a FileNode's basename with locale-aware overrides:
541
565
  * - `.trash` directory → "Çöp Kutusu" / "Trash"
@@ -627,6 +651,8 @@ export function useLocale(
627
651
  return {
628
652
  t,
629
653
  formatSize,
654
+ formatNodeSize,
655
+ nodeSizeHint,
630
656
  formatDate,
631
657
  formatDateFull,
632
658
  formatMonthYear,
@@ -27,7 +27,9 @@ import { computed, getCurrentScope, onScopeDispose, ref } from 'vue';
27
27
 
28
28
  /** `plugin` — an app-plugin job (docs/APP-PLUGINS-API.md); also what any
29
29
  * queue kind this package has no drawing for is shown as. */
30
- export type OperationKind = 'upload' | 'copy' | 'move' | 'delete' | 'convert' | 'archive' | 'plugin' | 'trash';
30
+ export type OperationKind =
31
+ | 'upload' | 'copy' | 'move' | 'delete' | 'convert'
32
+ | 'archive' | 'archive-create' | 'archive-extract' | 'plugin' | 'trash';
31
33
  export type OperationStatus = 'running' | 'done' | 'error' | 'aborted';
32
34
 
33
35
  /** What a publisher hands to `sync()` for one row. */
@@ -47,6 +49,8 @@ export interface OperationInput {
47
49
  errorDetail?: string | null;
48
50
  /** Queue op accepted but not started yet ("Queued"). */
49
51
  queued?: boolean;
52
+ /** Cancellation was accepted and the worker is stopping/cleaning up. */
53
+ cancelling?: boolean;
50
54
  /** Progress counters for queue ops (3/5 items). */
51
55
  doneCount?: number;
52
56
  totalCount?: number;
@@ -90,6 +94,7 @@ export interface Operation {
90
94
  error: string | null;
91
95
  errorDetail: string | null;
92
96
  queued: boolean;
97
+ cancelling: boolean;
93
98
  doneCount: number | null;
94
99
  totalCount: number | null;
95
100
  uploadedBytes: number | null;
@@ -117,6 +122,7 @@ function toOperation(key: string, input: OperationInput, prev?: Operation): Oper
117
122
  error: input.error ?? null,
118
123
  errorDetail: input.errorDetail ?? null,
119
124
  queued: input.queued ?? false,
125
+ cancelling: input.cancelling ?? false,
120
126
  doneCount: input.doneCount ?? null,
121
127
  totalCount: input.totalCount ?? null,
122
128
  uploadedBytes: input.uploadedBytes ?? null,
@@ -33,7 +33,7 @@ export interface PendingOp {
33
33
  id: number;
34
34
  op_type: PendingOpType;
35
35
  /** `cancelled` — somebody stopped it (POST /ops/{id}/cancel). */
36
- status: 'pending' | 'running' | 'done' | 'error' | 'cancelled';
36
+ status: 'pending' | 'running' | 'cancelling' | 'done' | 'error' | 'cancelled';
37
37
  progress_total: number;
38
38
  progress_done: number;
39
39
  /** Running cross-storage transfer's bytes (issue #27); absent otherwise. */
@@ -48,6 +48,7 @@ export interface PendingOp {
48
48
  error_code?: string;
49
49
  /** The engine a job needed and the server lacks (`error_code: engine_missing`). */
50
50
  error_engine?: string;
51
+ cancellable: boolean;
51
52
  started_at: string | null;
52
53
  finished_at: string | null;
53
54
  created_at: string | null;
@@ -94,9 +95,11 @@ export function normalizeOp(raw: Record<string, unknown>): PendingOp {
94
95
  ? 'error'
95
96
  : rawStatus === 'cancelled'
96
97
  ? 'cancelled'
97
- : rawStatus === 'running'
98
- ? 'running'
99
- : 'pending';
98
+ : rawStatus === 'cancelling'
99
+ ? 'cancelling'
100
+ : rawStatus === 'running'
101
+ ? 'running'
102
+ : 'pending';
100
103
  const sources = Array.isArray(raw.sources) ? raw.sources : [];
101
104
  // ⚠ Unknown kinds pass through. This used to fold everything that was not
102
105
  // copy/move into `delete`, which drew a bin beside a plugin job and read
@@ -124,6 +127,7 @@ export function normalizeOp(raw: Record<string, unknown>): PendingOp {
124
127
  error_message: str(raw.error_message, raw.error),
125
128
  error_code: str(raw.error_code) ?? undefined,
126
129
  error_engine: str(raw.error_engine) ?? undefined,
130
+ cancellable: raw.cancellable === true,
127
131
  started_at: str(raw.started_at),
128
132
  finished_at: str(raw.finished_at),
129
133
  created_at: str(raw.created_at),
@@ -155,7 +159,7 @@ export function usePendingOps(
155
159
  let firstPollDone = false;
156
160
 
157
161
  const hasActive = computed(() =>
158
- ops.value.some((o) => o.status === 'pending' || o.status === 'running'),
162
+ ops.value.some((o) => o.status === 'pending' || o.status === 'running' || o.status === 'cancelling'),
159
163
  );
160
164
 
161
165
  let timer: ReturnType<typeof setInterval> | undefined;
@@ -197,7 +201,7 @@ export function usePendingOps(
197
201
  // is currently seeing them in the local tray.
198
202
  const localIds = new Set(ops.value.map((o) => o.id));
199
203
  const visibleIncoming = incoming.filter((op) => {
200
- if (op.status === 'pending' || op.status === 'running') return true;
204
+ if (op.status === 'pending' || op.status === 'running' || op.status === 'cancelling') return true;
201
205
  if (!firstPollDone) return false;
202
206
  return localIds.has(op.id);
203
207
  });
@@ -211,7 +215,7 @@ export function usePendingOps(
211
215
  // Sweep: drop terminal ops past RETAIN_MS.
212
216
  const now = Date.now();
213
217
  const after = merged.filter((o) => {
214
- if (o.status === 'pending' || o.status === 'running') return true;
218
+ if (o.status === 'pending' || o.status === 'running' || o.status === 'cancelling') return true;
215
219
  const at = settledAt.get(o.id);
216
220
  return at !== undefined && now - at < RETAIN_MS;
217
221
  });