@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
@@ -1,4 +1,4 @@
1
- // useThumbs — authenticated thumbnail loader for the grid view.
1
+ // useThumbs — authenticated thumbnail loader for the folder views.
2
2
  //
3
3
  // The backend emits `thumb_url` as a ROOT-RELATIVE path ("/api/files/thumb/{id}").
4
4
  // A plain `<img src>` only works for the native same-origin SPA: an embedded
@@ -6,9 +6,24 @@
6
6
  // and, even with the URL fixed, `<img>` cannot carry the bearer header a
7
7
  // proxied host (fishapp PWA) requires. So thumbs are fetched through the same
8
8
  // auth machinery as every API call (headers + credentials), cached as object
9
- // URLs, and handed to the grid; a failed fetch falls back to the file icon.
9
+ // URLs, and handed to the views; a failed fetch falls back to the file icon.
10
+ //
11
+ // ⚠⚠ An arrival wakes ONE tile. The object URLs used to live in one reactive
12
+ // record that was REPLACED as each thumbnail arrived, so every arrival woke
13
+ // everything that had read any thumbnail — the whole folder view, which read
14
+ // them all. Field report: a folder of 344 files (~240 thumbnails) re-rendered
15
+ // itself ~240 times in a few seconds; a fast Mac lost ~4 s, a slower Windows PC
16
+ // froze Edge for ~30 s and ran a Chrome tab out of memory. The cache is now a
17
+ // reactive Map read per key, and the views read it through ThumbTile (one
18
+ // component per tile), so an arrival re-renders the tile that shows it.
19
+ //
20
+ // ⚠ Keyed by picture, not by URL. `thumb_url` carries a signature whose expiry
21
+ // moves every hour; keyed by URL, the first listing after the hour turned
22
+ // fetched every thumbnail of the folder again. The key is the URL's path plus
23
+ // the file's `last_modified`: a rotated signature is the same picture, an edited
24
+ // file is not.
10
25
 
11
- import { ref } from 'vue';
26
+ import { shallowReactive, toRaw } from 'vue';
12
27
  import type { FileNode } from '../types/FileNode';
13
28
 
14
29
  interface ThumbApiSlice {
@@ -20,10 +35,22 @@ interface ThumbApiSlice {
20
35
  * small (~KBs) so this is generous while still bounding a long session. */
21
36
  const MAX_CACHED = 500;
22
37
 
38
+ /** The cache key for a node's thumbnail — null when it has none. */
39
+ function thumbKey(n: FileNode): string | null {
40
+ const raw = n.thumb_url;
41
+ if (!raw) return null;
42
+ const q = raw.indexOf('?');
43
+ return `${q === -1 ? raw : raw.slice(0, q)}|${n.last_modified ?? ''}`;
44
+ }
45
+
23
46
  export function useThumbs(apiBase: string | undefined, api: ThumbApiSlice) {
24
- const urls = ref<Record<string, string>>({});
47
+ const cache = shallowReactive(new Map<string, string>());
25
48
  const order: string[] = [];
49
+ /** Signed URLs that failed. Per URL, not per key: 404 (thumb evicted
50
+ * server-side) or an auth hiccup is not retried within the session, but a
51
+ * freshly signed URL for the same picture may be tried once. */
26
52
  const failed = new Set<string>();
53
+ /** Keys being fetched — one request per picture however many URLs name it. */
27
54
  const pending = new Set<string>();
28
55
 
29
56
  function resolveUrl(raw: string): string {
@@ -32,19 +59,21 @@ export function useThumbs(apiBase: string | undefined, api: ThumbApiSlice) {
32
59
  return base && raw.startsWith('/') ? base + raw : raw;
33
60
  }
34
61
 
35
- /** Reactive: returns the loaded object URL for the node's thumb, kicking off
36
- * the fetch on first sight. null = not (yet) available → show the icon. */
62
+ /** Reactive, per node: returns the loaded object URL for the node's thumb,
63
+ * kicking off the fetch on first sight. null = not (yet) available → show
64
+ * the icon. Only what read THIS node's key is woken when it arrives. */
37
65
  function src(n: FileNode): string | null {
38
66
  const raw = n.thumb_url;
39
- if (!raw) return null;
40
- const got = urls.value[raw];
67
+ const key = thumbKey(n);
68
+ if (!raw || !key) return null;
69
+ const got = cache.get(key);
41
70
  if (got) return got;
42
- if (!failed.has(raw) && !pending.has(raw)) void load(raw);
71
+ if (!failed.has(raw) && !pending.has(key)) void load(raw, key);
43
72
  return null;
44
73
  }
45
74
 
46
- async function load(raw: string): Promise<void> {
47
- pending.add(raw);
75
+ async function load(raw: string, key: string): Promise<void> {
76
+ pending.add(key);
48
77
  try {
49
78
  const res = await fetch(resolveUrl(raw), {
50
79
  headers: await api.authHeaders({ Accept: '*/*' }),
@@ -53,22 +82,25 @@ export function useThumbs(apiBase: string | undefined, api: ThumbApiSlice) {
53
82
  if (!res.ok) throw new Error(String(res.status));
54
83
  const blob = await res.blob();
55
84
  if (order.length >= MAX_CACHED) {
85
+ // ⚠ Dropped without a word to the tile still showing it: woken, the
86
+ // tile would fetch it again, and that arrival would drop the next
87
+ // oldest — past MAX_CACHED thumbnails on screen, a loop that never
88
+ // ends. Its <img> has long loaded, and a loaded image outlives its
89
+ // revoked URL; a tile drawn later asks again, once.
56
90
  const evict = order.shift();
57
- if (evict && urls.value[evict]) {
58
- URL.revokeObjectURL(urls.value[evict]);
59
- const next = { ...urls.value };
60
- delete next[evict];
61
- urls.value = next;
91
+ const store = toRaw(cache);
92
+ const old = evict ? store.get(evict) : undefined;
93
+ if (evict && old) {
94
+ URL.revokeObjectURL(old);
95
+ store.delete(evict);
62
96
  }
63
97
  }
64
- order.push(raw);
65
- urls.value = { ...urls.value, [raw]: URL.createObjectURL(blob) };
98
+ order.push(key);
99
+ cache.set(key, URL.createObjectURL(blob));
66
100
  } catch {
67
- // 404 (thumb evicted server-side) or auth hiccup — icon fallback, and we
68
- // don't retry within this session to avoid hammering.
69
101
  failed.add(raw);
70
102
  } finally {
71
- pending.delete(raw);
103
+ pending.delete(key);
72
104
  }
73
105
  }
74
106
 
package/src/index.ts CHANGED
@@ -32,6 +32,17 @@ export { default as StarButton } from './components/StarButton.vue';
32
32
  export { default as TagPicker } from './components/TagPicker.vue';
33
33
  export { default as TagKindIcon } from './components/TagKindIcon.vue';
34
34
  export { tagItemsOf, tagKey, isTagKind, type TagItem, type TagKind } from './lib/tags';
35
+ export {
36
+ coverageByStorage,
37
+ coverageMessage,
38
+ coverageNotice,
39
+ coveragePercent,
40
+ incomplete as coverageIncomplete,
41
+ type CatalogCoverage,
42
+ type CoverageNotice,
43
+ type CoverageReason,
44
+ type StorageInfo,
45
+ } from './lib/catalogCoverage';
35
46
  // tablo:t3 — the row's ONE action control ("Actions" / "Aksiyon"). DataTable
36
47
  // draws it for every table (`rowActions`); it stays exported for a host that
37
48
  // needs the same control outside a table. A second menu built beside
@@ -333,6 +344,10 @@ export type { NavApp } from './components/SideNav.vue';
333
344
  export { opPercent } from './lib/opProgress';
334
345
  export type { OpProgressLike } from './lib/opProgress';
335
346
 
347
+ /* #48 — how an archive format is written (ZIP, TAR.GZ, 7z), for the explorer's
348
+ * create dialog and a host's own archive settings alike. */
349
+ export { archiveFormatLabel } from './lib/archiveFormats';
350
+
336
351
  /* tasi:m1 — "download the selection as one archive". Exported so a host that
337
352
  * draws its own selection bar gets the real two-step flow (authorized mint,
338
353
  * then a navigation that streams) instead of reaching for window.open per
@@ -66,6 +66,12 @@ const GLYPHS: Record<string, string> = {
66
66
  '<path d="M9.5 4.5v5"/>',
67
67
  preview: EYE,
68
68
  download: TRAY + '<path d="M12 4v11.5"/><path d="M7.5 11L12 15.5 16.5 11"/>',
69
+ 'archive-create':
70
+ '<path d="M5 8.5h14v11H5z"/><path d="M4 4h16v4.5H4z"/>' +
71
+ '<path d="M12 11v5"/><path d="M9.5 13.5h5"/>',
72
+ 'archive-extract':
73
+ '<path d="M5 8.5h14v11H5z"/><path d="M4 4h16v4.5H4z"/>' +
74
+ '<path d="M12 10.5v6"/><path d="M9.5 14l2.5 2.5 2.5-2.5"/>',
69
75
  upload:
70
76
  '<path d="M4.5 15.5v3A2 2 0 0 0 6.5 20.5h11a2 2 0 0 0 2-2v-3"/>' +
71
77
  '<path d="M12 15.5V4"/><path d="M7.5 8.5L12 4l4.5 4.5"/>',
@@ -0,0 +1,12 @@
1
+ /**
2
+ * archiveFormatLabel — how an archive format is written wherever a person
3
+ * reads it: the create dialog, the admin Archives page, its provider cards.
4
+ *
5
+ * Upper case (ZIP, TAR.GZ, RAR), except 7z, which its own project writes in
6
+ * lower case. One rule in one place: the dialog wrote "7z" while the admin
7
+ * page wrote "7Z" for the same format (measured, 0.44.0 integration of #48).
8
+ */
9
+ export function archiveFormatLabel(format: string): string {
10
+ const f = format.trim().toLowerCase();
11
+ return f === '7z' ? '7z' : f.toUpperCase();
12
+ }
@@ -0,0 +1,61 @@
1
+ export interface CachedArchiveEntry {
2
+ name: string;
3
+ size: number;
4
+ mtime?: string;
5
+ is_dir?: boolean;
6
+ }
7
+
8
+ export interface ArchivePreviewCache {
9
+ recall(path: string): CachedArchiveEntry[] | undefined;
10
+ remember(path: string, entries: CachedArchiveEntry[]): void;
11
+ forget(path: string): void;
12
+ clear(): void;
13
+ }
14
+
15
+ export const ARCHIVE_PREVIEW_CACHE_TTL_MS = 2 * 60 * 1000;
16
+
17
+ export function createArchivePreviewCache(
18
+ ttlMs = ARCHIVE_PREVIEW_CACHE_TTL_MS,
19
+ now: () => number = Date.now,
20
+ ): ArchivePreviewCache {
21
+ type Cached = {
22
+ entries: CachedArchiveEntry[];
23
+ expiresAt: number;
24
+ timer: ReturnType<typeof setTimeout>;
25
+ };
26
+ const values = new Map<string, Cached>();
27
+
28
+ function forget(path: string): void {
29
+ const cached = values.get(path);
30
+ if (cached) clearTimeout(cached.timer);
31
+ values.delete(path);
32
+ }
33
+
34
+ return {
35
+ recall(path) {
36
+ const cached = values.get(path);
37
+ if (!cached) return undefined;
38
+ if (cached.expiresAt <= now()) {
39
+ forget(path);
40
+ return undefined;
41
+ }
42
+ return cached.entries.map((entry) => ({ ...entry }));
43
+ },
44
+ remember(path, entries) {
45
+ forget(path);
46
+ const cached: Cached = {
47
+ entries: entries.map((entry) => ({ ...entry })),
48
+ expiresAt: now() + ttlMs,
49
+ timer: setTimeout(() => {
50
+ if (values.get(path) === cached) values.delete(path);
51
+ }, ttlMs),
52
+ };
53
+ values.set(path, cached);
54
+ },
55
+ forget,
56
+ clear() {
57
+ for (const cached of values.values()) clearTimeout(cached.timer);
58
+ values.clear();
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * catalogCoverage — how much of a storage its catalog covers, as the server
3
+ * says it, and the one set of rules every view draws that by.
4
+ *
5
+ * filex keeps a catalog of every storage, and search, folder sizes and drive
6
+ * usage are answered from it. While it does not cover a storage in full the
7
+ * server says so (docs/LAZY-CATALOGUE.md → "Coverage in the UI"):
8
+ *
9
+ * - `first_scan` — any storage whose first sync has not finished;
10
+ * - `lazy_filling` — a lazily cataloged storage whose background pass is
11
+ * still going (behaviour A);
12
+ * - `lazy_on_open` — a lazily cataloged storage that catalogs only the
13
+ * folders people open (behaviour B).
14
+ *
15
+ * The listing and search responses carry it per storage in `storage_info`,
16
+ * drive usage per row. A folder row whose own size leaves something out
17
+ * carries `size_partial` (see useLocale `formatNodeSize`).
18
+ *
19
+ * ⚠ One module for the whole explorer: the banner over a listing, the banner
20
+ * over search results and Home's drive cards read the SAME object through the
21
+ * SAME rules, so they cannot tell a person two different things about one
22
+ * storage.
23
+ */
24
+
25
+ /** Why a storage's catalog is not complete. */
26
+ export type CoverageReason = 'first_scan' | 'lazy_filling' | 'lazy_on_open';
27
+
28
+ /** The server's `coverage` object (sync.CatalogueCoverage). */
29
+ export interface CatalogCoverage {
30
+ complete: boolean;
31
+ reason?: CoverageReason | string;
32
+ /** `lazy` for a lazily cataloged storage. */
33
+ mode?: string;
34
+ /** The lazy behaviour: `background` (A) or `on_open` (B). */
35
+ fill?: string;
36
+ catalogued_folders?: number;
37
+ pending_folders?: number;
38
+ watched_folders?: number;
39
+ max_watches?: number;
40
+ filler?: string;
41
+ held_back_folders?: number;
42
+ reconciles?: number;
43
+ }
44
+
45
+ /** One entry of a listing's `storage_info`. */
46
+ export interface StorageInfo {
47
+ name: string;
48
+ read_only?: boolean;
49
+ coverage?: CatalogCoverage | null;
50
+ }
51
+
52
+ /** A coverage object worth telling anybody about, or null. */
53
+ export function incomplete(c: CatalogCoverage | null | undefined): CatalogCoverage | null {
54
+ if (!c || typeof c !== 'object' || c.complete === true) return null;
55
+ return c;
56
+ }
57
+
58
+ /**
59
+ * The share of the folders found so far that are cataloged, 0–99 while any
60
+ * is still waiting (a floor, so "100%" is never shown beside a notice that
61
+ * says the work is not done).
62
+ */
63
+ export function coveragePercent(c: CatalogCoverage): number {
64
+ const done = Math.max(0, c.catalogued_folders ?? 0);
65
+ const pending = Math.max(0, c.pending_folders ?? 0);
66
+ if (done + pending === 0) return 0;
67
+ const pct = Math.floor((done * 100) / (done + pending));
68
+ return pending > 0 ? Math.min(pct, 99) : pct;
69
+ }
70
+
71
+ /** The sentence one storage's coverage is told in: a catalog key and its values. */
72
+ export function coverageMessage(c: CatalogCoverage): { key: string; vars: Record<string, number> } {
73
+ switch (c.reason) {
74
+ case 'lazy_on_open':
75
+ return { key: 'coverage.lazy_on_open', vars: {} };
76
+ case 'lazy_filling':
77
+ return { key: 'coverage.lazy_filling', vars: { pct: coveragePercent(c) } };
78
+ default:
79
+ return { key: 'coverage.first_scan', vars: {} };
80
+ }
81
+ }
82
+
83
+ /** `storage_info` → storage name → its incomplete coverage (null = complete). */
84
+ export function coverageByStorage(info: unknown): Record<string, CatalogCoverage | null> {
85
+ const out: Record<string, CatalogCoverage | null> = {};
86
+ if (!Array.isArray(info)) return out;
87
+ for (const row of info as StorageInfo[]) {
88
+ if (!row || typeof row.name !== 'string' || row.name === '') continue;
89
+ out[row.name] = incomplete(row.coverage ?? null);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /** What the banner over a listing or a search says, and whether it offers an action. */
95
+ export interface CoverageNotice {
96
+ key: string;
97
+ vars: Record<string, string | number>;
98
+ /** The storage the notice is about, when it is about one. */
99
+ storage?: string;
100
+ /** Offer "Catalog everything" (an administrator, behaviour B). */
101
+ offerCatalogAll: boolean;
102
+ }
103
+
104
+ /**
105
+ * The notice for what is on screen.
106
+ *
107
+ * - A listing, or a search inside one storage: that storage's coverage.
108
+ * - A search that spans every storage (the server searches them all when
109
+ * the query is typed at a storage's root and there is more than one):
110
+ * the names of the ones not fully cataloged.
111
+ *
112
+ * `admin` decides the action only; everybody reads the same sentence.
113
+ */
114
+ export function coverageNotice(opts: {
115
+ map: Record<string, CatalogCoverage | null>;
116
+ adapter: string;
117
+ searching: boolean;
118
+ crossStorage: boolean;
119
+ admin: boolean;
120
+ }): CoverageNotice | null {
121
+ const { map, adapter, searching, crossStorage, admin } = opts;
122
+ if (searching && crossStorage) {
123
+ const names = Object.keys(map)
124
+ .filter((n) => map[n] !== null)
125
+ .sort((a, b) => a.localeCompare(b));
126
+ if (names.length === 0) return null;
127
+ if (names.length === 1) return single(names[0], map[names[0]] as CatalogCoverage, admin);
128
+ return { key: 'coverage.search_some', vars: { names: names.join(', ') }, offerCatalogAll: false };
129
+ }
130
+ const c = map[adapter];
131
+ return c ? single(adapter, c, admin) : null;
132
+ }
133
+
134
+ function single(storage: string, c: CatalogCoverage, admin: boolean): CoverageNotice {
135
+ const m = coverageMessage(c);
136
+ return { key: m.key, vars: m.vars, storage, offerCatalogAll: admin && c.reason === 'lazy_on_open' };
137
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * whenNearViewport — call `cb` once `el` comes within NEAR of the viewport.
3
+ *
4
+ * A folder view puts every tile on the page at once, and a thumbnail is a
5
+ * request: a folder of 344 files asked for ~240 of them the moment it opened,
6
+ * most for tiles nobody had scrolled to. ThumbTile asks only once this says
7
+ * its tile is close.
8
+ *
9
+ * ONE IntersectionObserver for the page, not one per tile, and each element is
10
+ * heard once and then forgotten. With a null root it watches the viewport, and
11
+ * a tile hidden by the scroll box it sits in (the explorer's pane scrolls on
12
+ * its own) does not count as near. Where IntersectionObserver does not exist
13
+ * (an old engine, a test DOM) every element counts as near at once — which is
14
+ * how every tile behaved before.
15
+ */
16
+
17
+ /** How far outside the viewport a tile already counts as near: a thumbnail
18
+ * should be on its way before the tile scrolls in, not after. */
19
+ const NEAR = '300px';
20
+
21
+ let io: IntersectionObserver | null = null;
22
+ const waiting = new Map<Element, Set<() => void>>();
23
+
24
+ function observer(): IntersectionObserver {
25
+ if (!io) {
26
+ io = new IntersectionObserver(
27
+ (entries) => {
28
+ for (const e of entries) {
29
+ if (!e.isIntersecting) continue;
30
+ const cbs = waiting.get(e.target);
31
+ if (!cbs) continue;
32
+ waiting.delete(e.target);
33
+ io?.unobserve(e.target);
34
+ cbs.forEach((cb) => cb());
35
+ }
36
+ },
37
+ // `rootMargin` widens the viewport only: a tile in the pane's own scroll
38
+ // box is still cut off at that box's edge. `scrollMargin` widens the
39
+ // scroll boxes too (Chromium 120+; an engine that does not know it
40
+ // ignores it, and the picture is asked for as the tile scrolls in).
41
+ { rootMargin: NEAR, scrollMargin: NEAR } as IntersectionObserverInit,
42
+ );
43
+ }
44
+ return io;
45
+ }
46
+
47
+ /** Call `cb` once `el` is near the viewport. Returns a function that cancels
48
+ * the wait (call it when the element goes away first). */
49
+ export function whenNearViewport(el: Element | null | undefined, cb: () => void): () => void {
50
+ if (!el || typeof IntersectionObserver === 'undefined') {
51
+ cb();
52
+ return () => {};
53
+ }
54
+ let cbs = waiting.get(el);
55
+ if (!cbs) {
56
+ cbs = new Set();
57
+ waiting.set(el, cbs);
58
+ observer().observe(el);
59
+ }
60
+ cbs.add(cb);
61
+ return () => {
62
+ const set = waiting.get(el);
63
+ if (!set) return;
64
+ set.delete(cb);
65
+ if (set.size === 0) {
66
+ waiting.delete(el);
67
+ io?.unobserve(el);
68
+ }
69
+ };
70
+ }
71
+
72
+ /** Tests only: drop the shared observer so the next wait builds a fresh one. */
73
+ export function __resetNearViewport(): void {
74
+ io?.disconnect();
75
+ io = null;
76
+ waiting.clear();
77
+ }
package/src/locales/en.ts CHANGED
@@ -12,6 +12,8 @@ export const en: Record<string, string> = {
12
12
  'ctx.open': 'Open',
13
13
  'ctx.preview': 'Preview',
14
14
  'ctx.download': 'Download',
15
+ 'ctx.archive_create': 'Create archive…',
16
+ 'ctx.archive_extract': 'Extract archive…',
15
17
  'ctx.convert': 'Convert',
16
18
  'ctx.rename': 'Rename',
17
19
  'ctx.delete': 'Delete',
@@ -57,6 +59,51 @@ export const en: Record<string, string> = {
57
59
  'modal.newfolder.cancel': 'Cancel',
58
60
 
59
61
  'modal.share.title': 'Share link',
62
+ 'archive.create_title': 'Create archive',
63
+ 'archive.extract_title': 'Extract archive',
64
+ 'archive.create_count': '{count} selected items',
65
+ 'archive.create_count_one': '{count} selected item',
66
+ 'archive.filename': 'Archive name',
67
+ 'archive.format': 'Format',
68
+ 'archive.password_optional': 'Password (optional)',
69
+ 'archive.password_confirm': 'Confirm password',
70
+ 'archive.password_mismatch': 'Passwords do not match.',
71
+ 'archive.encrypt_names': 'Encrypt file names',
72
+ 'archive.compression': 'Compression',
73
+ 'archive.solid': 'Create solid archive',
74
+ 'archive.dictionary_size': 'Dictionary size',
75
+ 'archive.dictionary_size_value': '{n} MiB',
76
+ 'archive.dictionary_hint': 'Larger dictionaries can improve compression but use more memory while creating and extracting.',
77
+ 'archive.destination_folder': 'Extract to folder',
78
+ 'archive.create': 'Create',
79
+ 'archive.creating': 'Creating…',
80
+ 'archive.extract': 'Extract',
81
+ 'archive.extract_here': 'Extract here',
82
+ 'archive.extract_to_folder': 'Extract to folder',
83
+ 'archive.extracting': 'Extracting…',
84
+ 'archive.cancel': 'Cancel',
85
+ 'archive.invalid_name': 'Enter a file name without slashes.',
86
+ 'archive.invalid_extension': 'Archive name must use one of the available format extensions.',
87
+ 'archive.invalid_folder': 'Enter a folder name without slashes.',
88
+ 'archive.password_required': 'This archive requires a password.',
89
+ 'archive.password_title': 'Archive password',
90
+ 'archive.continue': 'Continue',
91
+ 'archive.opening': 'Opening…',
92
+ 'archive.bad_password': 'The archive password is incorrect.',
93
+ 'archive.target_exists': 'An item with that name already exists. Choose another name or remove it first.',
94
+ 'archive.password_charset': 'A ZIP password can use only ASCII characters: unaccented letters, digits and ordinary punctuation. Choose 7z to use any characters.',
95
+ 'archive.error_unsupported': 'This archive cannot be extracted: this server cannot read its format, or it holds something filex does not extract (a link, a special file, or a file whose size it does not state).',
96
+ 'archive.error_limit': 'This archive is larger than the server allows to extract.',
97
+ 'archive.error_unavailable': 'This server cannot open this kind of archive right now.',
98
+ 'archive.created': 'Archive created',
99
+ 'archive.queued': 'Archive creation is running in the background',
100
+ 'archive.extracted': 'Extracted {count} files',
101
+ 'archive.extracted_one': 'Extracted {count} file',
102
+ 'archive.extraction_queued': 'Archive extraction is running in the background',
103
+ 'archive.extraction_completed': 'Extraction completed',
104
+ 'archive.extraction_cancelled': 'Extraction cancelled after {count} files',
105
+ 'archive.extraction_cancelled_one': 'Extraction cancelled after {count} file',
106
+
60
107
  'modal.share.pin': 'PIN protected',
61
108
  'modal.share.expires': 'Expires at',
62
109
  'modal.share.max_downloads': 'Download limit',
@@ -169,6 +216,9 @@ export const en: Record<string, string> = {
169
216
  'viewer.archive.error': 'Could not read archive contents.',
170
217
  'viewer.archive.unsupported': 'This kind of archive cannot be listed here. Download it to see what is inside.',
171
218
  'viewer.archive.too_large': 'This archive is too large to list here. Download it to see what is inside.',
219
+ 'viewer.archive.open': 'Open archive',
220
+ 'viewer.archive.root': 'Archive',
221
+ 'viewer.archive.location': 'Archive location',
172
222
  'viewer.name': 'Name',
173
223
  'viewer.size': 'Size',
174
224
 
@@ -314,6 +364,19 @@ export const en: Record<string, string> = {
314
364
  'search.in_content': 'In content',
315
365
  'search.truncated': 'More results than shown — narrow your search',
316
366
 
367
+ /* Catalog coverage (docs/LAZY-CATALOGUE.md, lib/catalogCoverage). One
368
+ sentence per reason; the banner sits over a listing and over search
369
+ results, and says what is left out and why. */
370
+ 'coverage.first_scan': 'This storage’s first sync has not finished. Until it does, search, folder sizes and usage leave part of it out.',
371
+ 'coverage.lazy_filling': 'filex is still cataloging this storage ({pct}% of the folders found so far). Until it finishes, search, folder sizes and usage leave part of it out.',
372
+ 'coverage.lazy_on_open': 'Only the folders people open on this storage are cataloged. Search, folder sizes and usage cover those folders only.',
373
+ 'coverage.search_some': 'Not everything on these storages is cataloged yet: {names}. This search may miss files there.',
374
+ 'coverage.catalog_all': 'Catalog everything',
375
+ 'coverage.catalog_started': 'Sync started — the whole storage is being cataloged.',
376
+ 'size.at_least': '≥ {size}',
377
+ 'size.partial_hint': 'Part of this folder is not cataloged yet, so its size is at least this.',
378
+ 'size.unknown_hint': 'This folder is not cataloged yet, so its size is not known.',
379
+
317
380
  /* === bag:b4 === */
318
381
  'toolbar.more': 'More actions',
319
382
  'toolbar.search.close': 'Close search',
@@ -461,6 +524,7 @@ export const en: Record<string, string> = {
461
524
  'opc.retry': 'Retry',
462
525
  'opc.dismiss': 'Dismiss',
463
526
  'opc.queued': 'Queued',
527
+ 'opc.cancelling': 'Cancel requested…',
464
528
  'opc.percent': '{n}%',
465
529
  'opc.status.running': 'In progress',
466
530
  'opc.status.done': 'Done',
@@ -677,6 +741,8 @@ export const en: Record<string, string> = {
677
741
  'plugin.pdf.date_order': 'Date order',
678
742
  'plugin.pdf.date_separator': 'Separator',
679
743
  'plugin.pdf.date_example': 'Example',
744
+ 'opc.kind.archive-create': 'Creating archive',
745
+ 'opc.kind.archive-extract': 'Extracting archive',
680
746
  /* === wiring:c4 — onboarding tour + accessibility + error card === */
681
747
  'tour.aria': 'Onboarding tour',
682
748
  'tour.restart': 'Restart the tour',
@@ -1392,6 +1458,7 @@ export const en: Record<string, string> = {
1392
1458
  'drive.search.hint_title': 'Search everywhere and run commands ({combo})',
1393
1459
  'drive.storage.used': '{used} of {total} used',
1394
1460
  'drive.storage.used_unlimited': '{used} used',
1461
+ 'drive.storage.used_partial': 'at least {used} used',
1395
1462
  'drive.storage.label': 'Storage',
1396
1463
  /* === gorunum:v3-shell — the Home view (the overview, inside the shell) ==
1397
1464
  * The section headings are `sidenav.storages` / `sidenav.recent` /