@brftech/filex-core 0.22.0 → 0.23.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.
@@ -1436,7 +1436,57 @@ async function refreshKept(): Promise<void> {
1436
1436
  // Shell went away mid-call; a stale entry only mislabels a menu item.
1437
1437
  }
1438
1438
  }
1439
- onMounted(() => void refreshKept());
1439
+
1440
+ /** The folder the engine is working on RIGHT NOW (null between runs). Drives
1441
+ * the ⟳ row badges and the bottom progress strip. */
1442
+ const keepActive = ref<{
1443
+ remote: string;
1444
+ phase: 'inventory' | 'plan' | 'transfer' | 'settling';
1445
+ done: number;
1446
+ total: number;
1447
+ } | null>(null);
1448
+
1449
+ async function refreshKeepStatus(): Promise<void> {
1450
+ const ds = desktopSync.value;
1451
+ if (!ds?.status) return;
1452
+ try {
1453
+ keepActive.value = (await ds.status()).active ?? null;
1454
+ } catch {
1455
+ keepActive.value = null;
1456
+ }
1457
+ }
1458
+
1459
+ // The shell pokes on every engine output line — during a transfer that can be
1460
+ // several a second, and each refresh is an IPC round-trip. Trailing-edge
1461
+ // throttle: at most one refresh per 300ms, and the FINAL poke always lands,
1462
+ // so the strip cannot get stuck showing a finished transfer.
1463
+ let keepPokeTimer: ReturnType<typeof setTimeout> | null = null;
1464
+ // The shell holds the callback and only drops it on the NEXT mount, so a poke
1465
+ // can arrive after this instance is gone — pokes then set refs nothing reads.
1466
+ // Cheap to make explicit rather than rely on that being harmless.
1467
+ let keepAlive = true;
1468
+ function onKeepPoke(): void {
1469
+ if (keepPokeTimer || !keepAlive) return;
1470
+ keepPokeTimer = setTimeout(() => {
1471
+ keepPokeTimer = null;
1472
+ if (!keepAlive) return;
1473
+ void refreshKeepStatus();
1474
+ void refreshKept();
1475
+ }, 300);
1476
+ }
1477
+
1478
+ onMounted(() => {
1479
+ void refreshKept();
1480
+ void refreshKeepStatus();
1481
+ desktopSync.value?.onChange?.(onKeepPoke);
1482
+ });
1483
+ onBeforeUnmount(() => {
1484
+ keepAlive = false;
1485
+ if (keepPokeTimer) {
1486
+ clearTimeout(keepPokeTimer);
1487
+ keepPokeTimer = null;
1488
+ }
1489
+ });
1440
1490
 
1441
1491
  /** Adapter-qualified remote for a row. Virtual storage rows carry a bare
1442
1492
  * name (`docs`), real rows a wire path (`docs://reports`). */
@@ -1447,26 +1497,75 @@ function keepRemoteOf(node: FileNode): string {
1447
1497
 
1448
1498
  type KeepState = 'none' | 'kept' | 'inherited' | 'partial';
1449
1499
 
1500
+ /** True when `child` lives strictly inside `parent` (both wire-form). */
1501
+ function remoteInside(child: string, parent: string): boolean {
1502
+ if (parent.endsWith('://')) return child.startsWith(parent) && child !== parent;
1503
+ return child === parent ? false : child.startsWith(parent + '/');
1504
+ }
1505
+
1450
1506
  /** How `remote` relates to the kept set: exactly a pair, inside one
1451
1507
  * (inherited), an ancestor of some (partial), or unrelated. */
1452
1508
  function keepStateOf(remote: string): KeepState {
1453
- const inside = (child: string, parent: string) =>
1454
- parent.endsWith('://')
1455
- ? child.startsWith(parent) && child !== parent
1456
- : child === parent
1457
- ? false
1458
- : child.startsWith(parent + '/');
1459
1509
  if (keptPairs.value.some((p) => p.remote === remote)) return 'kept';
1460
- if (keptPairs.value.some((p) => inside(remote, p.remote))) return 'inherited';
1461
- if (keptPairs.value.some((p) => inside(p.remote, remote))) return 'partial';
1510
+ if (keptPairs.value.some((p) => remoteInside(remote, p.remote))) return 'inherited';
1511
+ if (keptPairs.value.some((p) => remoteInside(p.remote, remote))) return 'partial';
1462
1512
  return 'none';
1463
1513
  }
1464
1514
 
1465
- /** Menu entries for one selected folder, by its keep state. Empty for
1466
- * multi-selections, files, trash, encrypted folders, or a web mount. */
1515
+ type KeepBadge = 'kept' | 'syncing' | 'cloud' | 'partial';
1516
+
1517
+ /**
1518
+ * The availability badge for one row: on this computer, being synced right
1519
+ * now, holding kept items somewhere below (partial), or online-only. Every
1520
+ * row gets one — that is the OneDrive/Drive grammar people already read —
1521
+ * except the rows where it would be a lie or noise: trash, and the `.trash`
1522
+ * row itself. `partial` is what saves the user from drilling into every
1523
+ * folder to find out whether anything inside is on this computer.
1524
+ */
1525
+ function keepBadgeFor(n: FileNode): KeepBadge | null {
1526
+ if (!desktopSync.value || trashActive.value) return null;
1527
+ if (n.basename === '.trash') return null;
1528
+ const r = keepRemoteOf(n);
1529
+ const act = keepActive.value;
1530
+ if (act && (r === act.remote || remoteInside(r, act.remote) || remoteInside(act.remote, r))) {
1531
+ return 'syncing';
1532
+ }
1533
+ const st = keepStateOf(r);
1534
+ if (st === 'kept' || st === 'inherited') return 'kept';
1535
+ if (st === 'partial') return 'partial';
1536
+ return 'cloud';
1537
+ }
1538
+
1539
+ /** Bottom strip: what to say while the engine works. */
1540
+ const keepStripLabel = computed<string>(() => {
1541
+ const act = keepActive.value;
1542
+ if (!act) return '';
1543
+ const name = act.remote.endsWith('://')
1544
+ ? act.remote.slice(0, -'://'.length)
1545
+ : act.remote.slice(act.remote.lastIndexOf('/') + 1);
1546
+ if (act.phase === 'transfer' && act.total > 0) {
1547
+ return t('keep.strip_transfer', {
1548
+ name,
1549
+ done: String(act.done),
1550
+ total: String(act.total),
1551
+ pct: String(Math.min(100, Math.round((act.done * 100) / act.total))),
1552
+ });
1553
+ }
1554
+ if (act.phase === 'settling') return t('keep.strip_settling', { name });
1555
+ return t('keep.strip_inventory', { name });
1556
+ });
1557
+
1558
+ const keepStripPercent = computed<number | null>(() => {
1559
+ const act = keepActive.value;
1560
+ if (!act || act.phase !== 'transfer' || act.total <= 0) return null;
1561
+ return Math.min(100, Math.round((act.done * 100) / act.total));
1562
+ });
1563
+
1564
+ /** Menu entries for one selected folder OR file, by its keep state. Empty
1565
+ * for multi-selections, trash, encrypted folders, or a web mount. */
1467
1566
  function keepActionsFor(sel: FileNode[]): ContextAction[] {
1468
1567
  const ds = desktopSync.value;
1469
- const single = sel.length === 1 && sel[0]?.type === 'dir';
1568
+ const single = sel.length === 1 && (sel[0]?.type === 'dir' || sel[0]?.type === 'file');
1470
1569
  if (!ds || !single || trashActive.value || e2eActive.value || sel[0]?.e2e === true) return [];
1471
1570
  const st = keepStateOf(keepRemoteOf(sel[0]!));
1472
1571
  return [
@@ -1701,7 +1800,7 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
1701
1800
  if (!ds || !targets[0]) break;
1702
1801
  const remote = keepRemoteOf(targets[0]);
1703
1802
  try {
1704
- await ds.keep(remote);
1803
+ await ds.keep(remote, targets[0].type === 'file' ? 'file' : 'dir');
1705
1804
  await refreshKept();
1706
1805
  // The shell may have shown its root-folder prompt and been cancelled —
1707
1806
  // only claim success when the pair is really there now.
@@ -3514,6 +3613,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3514
3613
  :show-parent-path="!!searchQuery"
3515
3614
  :locale="locale"
3516
3615
  :loading="loading"
3616
+ :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
3517
3617
  :starred-ids="starredIds"
3518
3618
  :api-base="props.config.apiBase ?? ''"
3519
3619
  :auth-headers="() => buildAuthHeaders()"
@@ -3533,6 +3633,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3533
3633
  :show-parent-path="!!searchQuery"
3534
3634
  :locale="locale"
3535
3635
  :loading="loading"
3636
+ :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
3536
3637
  :thumb-src="thumbs.src"
3537
3638
  @click-card="(n, m) => selection.click(n.path, m)"
3538
3639
  @dbl-card="openNode"
@@ -3564,6 +3665,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3564
3665
  :key tab kimliğine bağlı — tab geçişinde pane kendi konumuyla temiz
3565
3666
  remount olur. -->
3566
3667
  <SecondaryPane
3668
+ :keep-badge-for="desktopSync ? keepBadgeFor : undefined"
3567
3669
  v-if="splitVisible && activeSplit"
3568
3670
  ref="splitPaneRef"
3569
3671
  :key="'split-' + tabsActiveId"
@@ -3669,6 +3771,16 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3669
3771
  </svg>
3670
3772
  </button>
3671
3773
 
3774
+ <!-- selective-sync progress: the folder the engine is moving RIGHT NOW.
3775
+ Overlay, pointer-events none — status is never in the way of work. -->
3776
+ <div v-if="keepActive" class="fe-keep-strip" role="status" aria-live="polite">
3777
+ <span class="fe-keep-strip__icon" aria-hidden="true">⟳</span>
3778
+ <span class="fe-keep-strip__label">{{ keepStripLabel }}</span>
3779
+ <div v-if="keepStripPercent !== null" class="fe-keep-strip__bar" aria-hidden="true">
3780
+ <div class="fe-keep-strip__fill" :style="{ width: keepStripPercent + '%' }"></div>
3781
+ </div>
3782
+ </div>
3783
+
3672
3784
  <ContextMenu
3673
3785
  ref="ctxRef"
3674
3786
  :locale="locale"
@@ -21,6 +21,9 @@ const props = defineProps<{
21
21
  * root-relative and unauthenticated — a bare <img src> only works for the
22
22
  * native same-origin SPA, so embedded hosts NEED this. null = icon. */
23
23
  thumbSrc?: (n: FileNode) => string | null;
24
+ /** Desktop selective sync: availability badge per tile. Absent on the
25
+ * web — no badge renders at all. */
26
+ keepBadgeFor?: (n: FileNode) => 'kept' | 'syncing' | 'cloud' | 'partial' | null;
24
27
  }>();
25
28
 
26
29
  const emit = defineEmits<{
@@ -123,6 +126,13 @@ function parentDir(path: string): string {
123
126
 
124
127
  // Special rows keep their emoji (trash/storage are not file-TYPE icons);
125
128
  // everything else renders the SVG icon set from lib/fileIcons.
129
+ function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
130
+ if (b === 'kept') return '\u2713';
131
+ if (b === 'syncing') return '\u27f3';
132
+ if (b === 'partial') return '\u25d0';
133
+ return '\u2601';
134
+ }
135
+
126
136
  function specialEmojiFor(n: FileNode): string | null {
127
137
  if (n.basename === '.trash') return '🗑';
128
138
  if (n.mime_type === 'inode/storage') return '💾';
@@ -205,6 +215,13 @@ function snippetTitle(snippet: string): string {
205
215
  </div>
206
216
  <div class="fe-grid__label" :title="n.basename">
207
217
  {{ nodeDisplayName(n) }}
218
+ <span
219
+ v-if="keepBadgeFor && keepBadgeFor(n)"
220
+ :class="['fe-keepbadge', 'fe-keepbadge--' + keepBadgeFor(n)]"
221
+ :title="t('keep.badge_' + keepBadgeFor(n))"
222
+ role="img"
223
+ :aria-label="t('keep.badge_' + keepBadgeFor(n))"
224
+ >{{ keepGlyph(keepBadgeFor(n)!) }}</span>
208
225
  </div>
209
226
  <div
210
227
  v-if="showParentPath"
@@ -39,6 +39,10 @@ const props = defineProps<{
39
39
  apiBase?: string;
40
40
  authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
41
41
  authCredentials?: RequestCredentials;
42
+ /** Desktop selective sync: availability badge per row (kept on this
43
+ * computer / syncing right now / online only). Absent on the web —
44
+ * no badge renders at all. */
45
+ keepBadgeFor?: (n: FileNode) => 'kept' | 'syncing' | 'cloud' | 'partial' | null;
42
46
  }>();
43
47
 
44
48
  const emit = defineEmits<{
@@ -144,6 +148,13 @@ function specialEmojiFor(n: FileNode): string | null {
144
148
  return null;
145
149
  }
146
150
 
151
+ function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
152
+ if (b === 'kept') return '\u2713';
153
+ if (b === 'syncing') return '\u27f3';
154
+ if (b === 'partial') return '\u25d0';
155
+ return '\u2601';
156
+ }
157
+
147
158
  function isPinnedSpecial(n: FileNode): boolean {
148
159
  return n.basename === '.trash' || n.mime_type === 'inode/storage';
149
160
  }
@@ -397,6 +408,13 @@ const segments = computed<Segment[]>(() => {
397
408
  {{ nodeDisplayName(n) }}
398
409
  <!-- bul:s3 — content-match badge -->
399
410
  <span v-if="rowInContent(n)" class="fe-list__badge">{{ t('search.in_content') }}</span>
411
+ <span
412
+ v-if="keepBadgeFor && keepBadgeFor(n)"
413
+ :class="['fe-keepbadge', 'fe-keepbadge--' + keepBadgeFor(n)]"
414
+ :title="t('keep.badge_' + keepBadgeFor(n))"
415
+ role="img"
416
+ :aria-label="t('keep.badge_' + keepBadgeFor(n))"
417
+ >{{ keepGlyph(keepBadgeFor(n)!) }}</span>
400
418
  </span>
401
419
  <span
402
420
  v-if="showParentPath"
@@ -58,6 +58,8 @@ const props = defineProps<{
58
58
  viewMode?: ViewMode;
59
59
  /** ui-fix — authenticated thumb resolver, forwarded to grid/gallery. */
60
60
  thumbSrc?: (n: FileNode) => string | null;
61
+ /** Desktop selective sync badge resolver, forwarded to the views. */
62
+ keepBadgeFor?: (n: FileNode) => 'kept' | 'syncing' | 'cloud' | 'partial' | null;
61
63
  /** ui-fix — mirror the main panel's virtual `.trash` row at storage root
62
64
  * so both split panes list identical rows (no row-offset). Defaults on. */
63
65
  trashVisible?: boolean;
@@ -396,6 +398,7 @@ defineExpose({ reload, goUp, selectAll, openSelected, selectedNodes, getPath });
396
398
  :selected="selected"
397
399
  :locale="locale"
398
400
  :loading="loading"
401
+ :keep-badge-for="keepBadgeFor"
399
402
  @click-row="onViewClick"
400
403
  @dbl-row="onRowDbl"
401
404
  @context-row="onViewContext"
@@ -409,6 +412,7 @@ defineExpose({ reload, goUp, selectAll, openSelected, selectedNodes, getPath });
409
412
  :locale="locale"
410
413
  :loading="loading"
411
414
  :thumb-src="thumbSrc"
415
+ :keep-badge-for="keepBadgeFor"
412
416
  @click-card="onViewClick"
413
417
  @dbl-card="onRowDbl"
414
418
  @context-card="onViewContext"
package/src/locales/en.ts CHANGED
@@ -27,8 +27,15 @@ export const en: Record<string, string> = {
27
27
  'ctx.keep_online': 'Keep online only',
28
28
  'ctx.keep_reveal': 'Open local folder',
29
29
  'ctx.keep_inherited': 'Kept on this computer with its parent',
30
- 'keep.started': 'This folder will be kept on this computer — sync started',
30
+ 'keep.started': 'Will be kept on this computer — sync started',
31
31
  'keep.failed': 'Could not keep on this computer',
32
+ 'keep.badge_kept': 'On this computer',
33
+ 'keep.badge_syncing': 'Syncing…',
34
+ 'keep.badge_cloud': 'Online only',
35
+ 'keep.badge_partial': 'Contains items kept on this computer',
36
+ 'keep.strip_transfer': 'Syncing {name} — {done}/{total} ({pct}%)',
37
+ 'keep.strip_inventory': 'Syncing {name} — scanning the server…',
38
+ 'keep.strip_settling': 'Syncing {name} — settling…',
32
39
  'ctx.paste': 'Paste',
33
40
  'ctx.info': 'Info',
34
41
  'ctx.duplicate': 'Duplicate',
package/src/locales/tr.ts CHANGED
@@ -27,8 +27,15 @@ export const tr: Record<string, string> = {
27
27
  'ctx.keep_online': 'Yalnızca çevrimiçi tut',
28
28
  'ctx.keep_reveal': 'Yerel klasörü aç',
29
29
  'ctx.keep_inherited': 'Üst klasörle bilgisayarda tutuluyor',
30
- 'keep.started': 'Klasör bilgisayarda tutulacak — eşitleme başladı',
30
+ 'keep.started': 'Bilgisayarda tutulacak — eşitleme başladı',
31
31
  'keep.failed': 'Bilgisayarda tutulamadı',
32
+ 'keep.badge_kept': 'Bilgisayarda',
33
+ 'keep.badge_syncing': 'Eşitleniyor…',
34
+ 'keep.badge_cloud': 'Yalnızca çevrimiçi',
35
+ 'keep.badge_partial': 'İçinde bilgisayarda tutulanlar var',
36
+ 'keep.strip_transfer': '{name} eşitleniyor — {done}/{total} (%{pct})',
37
+ 'keep.strip_inventory': '{name} eşitleniyor — sunucu taranıyor…',
38
+ 'keep.strip_settling': '{name} eşitleniyor — son durum kaydediliyor…',
32
39
  'ctx.paste': 'Yapıştır',
33
40
  'ctx.info': 'Bilgi',
34
41
  'ctx.duplicate': 'Kopyasını Oluştur',
@@ -3928,3 +3928,83 @@ filex-explorer {
3928
3928
  }
3929
3929
  }
3930
3930
  /* === /ui-fix ========================================================= */
3931
+
3932
+ /* ── selective sync (desktop shell only): availability badges + strip ──
3933
+ The glyphs read in the OneDrive/Drive grammar: ✓ on this computer,
3934
+ ⟳ being synced right now, ☁ online-only. Rendered only when the host
3935
+ passes keepBadgeFor — the web admin and the embeds never show them. */
3936
+ .fe-keepbadge {
3937
+ flex: 0 0 auto;
3938
+ display: inline-block;
3939
+ margin-left: 4px;
3940
+ font-size: 11px;
3941
+ line-height: 1;
3942
+ vertical-align: middle;
3943
+ }
3944
+ .fe-keepbadge--kept {
3945
+ color: var(--fe-keep-ok, #16a34a);
3946
+ }
3947
+ .fe-keepbadge--cloud {
3948
+ color: var(--fe-text-muted);
3949
+ opacity: 0.7;
3950
+ }
3951
+ .fe-keepbadge--partial {
3952
+ color: var(--fe-keep-ok, #16a34a);
3953
+ opacity: 0.85;
3954
+ }
3955
+ .fe-keepbadge--syncing {
3956
+ color: var(--fe-primary);
3957
+ animation: fe-keep-spin 1.2s linear infinite;
3958
+ }
3959
+ @keyframes fe-keep-spin {
3960
+ to {
3961
+ transform: rotate(360deg);
3962
+ }
3963
+ }
3964
+
3965
+ /* Bottom overlay strip: progress of the folder being synced right now.
3966
+ pointer-events none — it reports, it never blocks a click. */
3967
+ .fe-keep-strip {
3968
+ position: absolute;
3969
+ left: 12px;
3970
+ right: 12px;
3971
+ bottom: 10px;
3972
+ z-index: 30;
3973
+ display: flex;
3974
+ align-items: center;
3975
+ gap: 8px;
3976
+ padding: 6px 12px;
3977
+ border: 1px solid var(--fe-border);
3978
+ border-radius: 10px;
3979
+ background: var(--fe-bg-elev);
3980
+ box-shadow: var(--fe-shadow-sm, 0 2px 6px rgba(0, 0, 0, 0.18));
3981
+ font-size: 12.5px;
3982
+ color: var(--fe-text-muted);
3983
+ pointer-events: none;
3984
+ }
3985
+ .fe-keep-strip__icon {
3986
+ font-size: 13px;
3987
+ line-height: 1;
3988
+ color: var(--fe-primary);
3989
+ animation: fe-keep-spin 1.2s linear infinite;
3990
+ }
3991
+ .fe-keep-strip__label {
3992
+ flex: 0 1 auto;
3993
+ overflow: hidden;
3994
+ text-overflow: ellipsis;
3995
+ white-space: nowrap;
3996
+ }
3997
+ .fe-keep-strip__bar {
3998
+ flex: 1 1 80px;
3999
+ min-width: 60px;
4000
+ height: 4px;
4001
+ border-radius: 999px;
4002
+ background: color-mix(in srgb, var(--fe-primary) 18%, transparent);
4003
+ overflow: hidden;
4004
+ }
4005
+ .fe-keep-strip__fill {
4006
+ height: 100%;
4007
+ border-radius: 999px;
4008
+ background: var(--fe-primary);
4009
+ transition: width 0.3s ease;
4010
+ }
@@ -17,6 +17,7 @@
17
17
  --fe-primary-hover: #2563eb;
18
18
  --fe-danger: #dc2626;
19
19
  --fe-danger-hover: #b91c1c;
20
+ --fe-keep-ok: #16a34a; /* selective-sync "on this computer" check */
20
21
  --fe-shadow: 0 10px 32px rgba(15, 23, 42, 0.14);
21
22
  --fe-shadow-sm: 0 2px 6px rgba(15, 23, 42, 0.08);
22
23
  --fe-radius: 8px;
@@ -65,6 +66,7 @@
65
66
  --fe-primary-hover: #3b82f6;
66
67
  --fe-danger: #f87171;
67
68
  --fe-danger-hover: #ef4444;
69
+ --fe-keep-ok: #4ade80;
68
70
  --fe-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
69
71
  --fe-shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.25);
70
72
  }
@@ -85,6 +87,7 @@
85
87
  --fe-primary-hover: #3b82f6;
86
88
  --fe-danger: #f87171;
87
89
  --fe-danger-hover: #ef4444;
90
+ --fe-keep-ok: #4ade80;
88
91
  --fe-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
89
92
  --fe-shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.25);
90
93
  }
@@ -323,14 +323,32 @@ export interface ExplorerConfig {
323
323
  desktopSync?: {
324
324
  /** Kept folders for the mounted account, adapter-qualified remotes. */
325
325
  kept: () => Promise<Array<{ remote: string; local: string }>>;
326
- /** Start keeping a folder. Resolves once the pair is registered (or the
327
- * user cancelled the shell's root-folder prompt re-read `kept`). */
328
- keep: (remote: string) => Promise<void>;
326
+ /** Start keeping a folder or, with kind 'file', a single file.
327
+ * Resolves once the pair is registered (or the user cancelled the
328
+ * shell's root-folder prompt — re-read `kept`). */
329
+ keep: (remote: string, kind: 'dir' | 'file') => Promise<void>;
329
330
  /** Stop keeping. The SHELL owns the "what happens to the local copy"
330
331
  * question — it asks natively and may cancel; re-read `kept` after. */
331
332
  unkeep: (remote: string) => Promise<void>;
332
333
  /** Open the folder's local mirror in the OS file manager. */
333
334
  reveal: (remote: string) => Promise<void>;
335
+ /** Live engine state, for the row badges and the bottom progress strip.
336
+ * `active` is the folder being worked on right now — the engine walks
337
+ * its pairs one at a time — or null between runs. */
338
+ status?: () => Promise<{
339
+ running: boolean;
340
+ lastError?: string | null;
341
+ active: {
342
+ remote: string;
343
+ phase: 'inventory' | 'plan' | 'transfer' | 'settling';
344
+ done: number;
345
+ total: number;
346
+ } | null;
347
+ }>;
348
+ /** Subscribe to "something about sync changed" pokes from the shell —
349
+ * the explorer re-reads `kept` and `status` when poked. The shell keeps
350
+ * ONE subscriber (the mounted explorer) and overwrites it on remount. */
351
+ onChange?: (cb: () => void) => void;
334
352
  };
335
353
  }
336
354