@narumitw/pi-sync 0.54.2 → 0.54.3
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.
- package/README.md +19 -11
- package/dist/chunks/{chunk-ORQRHEEO.ts → chunk-26PMXY7E.ts} +3 -3
- package/dist/chunks/{chunk-SDZCTCYQ.ts → chunk-2FETCGGT.ts} +2 -2
- package/dist/chunks/{chunk-MYPSMJHJ.ts → chunk-35GM6XKM.ts} +275 -151
- package/dist/chunks/chunk-35GM6XKM.ts.map +7 -0
- package/dist/chunks/{chunk-V6VZ5TLG.ts → chunk-A3TFXY4O.ts} +2 -2
- package/dist/chunks/{chunk-5RZPINUQ.ts → chunk-AAZDBIBJ.ts} +2 -2
- package/dist/chunks/{chunk-5ZWQ33XL.ts → chunk-CAGJOKB7.ts} +5 -5
- package/dist/chunks/{chunk-QE7NZZWT.ts → chunk-E6ONFG57.ts} +15 -2
- package/dist/chunks/{chunk-QE7NZZWT.ts.map → chunk-E6ONFG57.ts.map} +2 -2
- package/dist/chunks/{chunk-IJ6VV6FN.ts → chunk-GC3QOL6J.ts} +2 -2
- package/dist/chunks/{chunk-VHXBCEVF.ts → chunk-HMXUKU43.ts} +3 -3
- package/dist/chunks/{chunk-4SBTAVFL.ts → chunk-I4JYVFUS.ts} +5 -6
- package/dist/chunks/chunk-I4JYVFUS.ts.map +7 -0
- package/dist/chunks/{chunk-2DI6PDZV.ts → chunk-ITFR6NNQ.ts} +14 -2
- package/dist/chunks/chunk-ITFR6NNQ.ts.map +7 -0
- package/dist/chunks/{chunk-3B4Z2SKV.ts → chunk-KATKRR3D.ts} +18 -11
- package/dist/chunks/chunk-KATKRR3D.ts.map +7 -0
- package/dist/chunks/{chunk-CL7BZRRQ.ts → chunk-KGE47GCZ.ts} +2 -2
- package/dist/chunks/{chunk-CHSIAC4N.ts → chunk-R35N4CL3.ts} +2 -2
- package/dist/chunks/chunk-UZPMDLTL.ts +54 -0
- package/dist/chunks/chunk-UZPMDLTL.ts.map +7 -0
- package/dist/chunks/{chunk-4QVPTIZB.ts → chunk-VCL7TZYW.ts} +2 -2
- package/dist/chunks/{chunk-WFKUKVCP.ts → chunk-XU2VXGQV.ts} +14 -8
- package/dist/chunks/chunk-XU2VXGQV.ts.map +7 -0
- package/dist/chunks/{chunk-UQ3ALBIB.ts → chunk-YTHQEW35.ts} +3 -3
- package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts → conflict-artifacts-4CZFB7WI.ts} +3 -3
- package/dist/chunks/{conflict-review-4PLVLMRO.ts → conflict-review-ZOTOHL2E.ts} +11 -10
- package/dist/chunks/{conflict-review-4PLVLMRO.ts.map → conflict-review-ZOTOHL2E.ts.map} +1 -1
- package/dist/chunks/{file-selection-WZX7HAY2.ts → file-selection-UNHBVVON.ts} +3 -3
- package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts → manager-result-dispatcher-HVRTPEVK.ts} +7 -7
- package/dist/chunks/{manager-ui-IURS4WXY.ts → manager-ui-ZIN4BUSF.ts} +297 -136
- package/dist/chunks/manager-ui-ZIN4BUSF.ts.map +7 -0
- package/dist/chunks/{merge-baseline-store-EVQAAML6.ts → merge-baseline-store-ICXVTCML.ts} +5 -3
- package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts → remote-selection-ui-H6AEVA3Q.ts} +6 -6
- package/dist/chunks/{s3-backend-AQGV6LFT.ts → s3-backend-Y4P3O5B5.ts} +2 -2
- package/dist/chunks/{setup-switch-KTUWL6DE.ts → setup-switch-ZMW5C4M7.ts} +4 -4
- package/dist/chunks/{setup-wizard-GSDPDFC4.ts → setup-wizard-VW7F3JN4.ts} +5 -5
- package/dist/chunks/{sync-inspection-K45QVLHB.ts → sync-inspection-3EMS2J5B.ts} +5 -4
- package/dist/chunks/{sync-operations-47OUX76C.ts → sync-operations-2CWST333.ts} +140 -24
- package/dist/chunks/sync-operations-2CWST333.ts.map +7 -0
- package/dist/index.ts +103 -68
- package/dist/index.ts.map +2 -2
- package/docs/settings.md +47 -10
- package/docs/tui-contract.md +61 -0
- package/package.json +1 -1
- package/src/commands/command-execution.ts +2 -1
- package/src/commands/command-types.ts +4 -0
- package/src/commands/command.ts +3 -1
- package/src/settings/config.ts +13 -10
- package/src/settings/settings-store.ts +1 -0
- package/src/settings/settings-types.ts +2 -0
- package/src/settings/settings-validation.ts +3 -0
- package/src/state/merge-baseline-store.ts +18 -0
- package/src/sync/apply-review.ts +63 -0
- package/src/sync/automatic-sync.ts +45 -33
- package/src/sync/merged-sync.ts +202 -104
- package/src/sync/startup-check.ts +39 -21
- package/src/sync/startup-timings.ts +90 -0
- package/src/sync/sync-inspection.ts +8 -4
- package/src/sync/sync-mutations.ts +49 -11
- package/src/sync/sync-queries.ts +14 -3
- package/src/ui/manager-state.ts +10 -6
- package/src/ui/manager-ui.ts +57 -7
- package/src/ui/merge-review.ts +3 -3
- package/src/ui/merged-sync-summary.ts +2 -18
- package/src/ui/settings-frame.ts +55 -0
- package/src/ui/settings-ui.ts +141 -43
- package/src/ui/setup/setup-prompts.ts +4 -3
- package/src/ui/sync-behavior.ts +15 -0
- package/src/ui/sync-format.ts +69 -38
- package/src/ui/transfer-changes.ts +33 -0
- package/src/ui/transfer-review.ts +27 -0
- package/dist/chunks/chunk-2DI6PDZV.ts.map +0 -7
- package/dist/chunks/chunk-3B4Z2SKV.ts.map +0 -7
- package/dist/chunks/chunk-4SBTAVFL.ts.map +0 -7
- package/dist/chunks/chunk-MYPSMJHJ.ts.map +0 -7
- package/dist/chunks/chunk-WFKUKVCP.ts.map +0 -7
- package/dist/chunks/manager-ui-IURS4WXY.ts.map +0 -7
- package/dist/chunks/sync-operations-47OUX76C.ts.map +0 -7
- /package/dist/chunks/{chunk-ORQRHEEO.ts.map → chunk-26PMXY7E.ts.map} +0 -0
- /package/dist/chunks/{chunk-SDZCTCYQ.ts.map → chunk-2FETCGGT.ts.map} +0 -0
- /package/dist/chunks/{chunk-V6VZ5TLG.ts.map → chunk-A3TFXY4O.ts.map} +0 -0
- /package/dist/chunks/{chunk-5RZPINUQ.ts.map → chunk-AAZDBIBJ.ts.map} +0 -0
- /package/dist/chunks/{chunk-5ZWQ33XL.ts.map → chunk-CAGJOKB7.ts.map} +0 -0
- /package/dist/chunks/{chunk-IJ6VV6FN.ts.map → chunk-GC3QOL6J.ts.map} +0 -0
- /package/dist/chunks/{chunk-VHXBCEVF.ts.map → chunk-HMXUKU43.ts.map} +0 -0
- /package/dist/chunks/{chunk-CL7BZRRQ.ts.map → chunk-KGE47GCZ.ts.map} +0 -0
- /package/dist/chunks/{chunk-CHSIAC4N.ts.map → chunk-R35N4CL3.ts.map} +0 -0
- /package/dist/chunks/{chunk-4QVPTIZB.ts.map → chunk-VCL7TZYW.ts.map} +0 -0
- /package/dist/chunks/{chunk-UQ3ALBIB.ts.map → chunk-YTHQEW35.ts.map} +0 -0
- /package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts.map → conflict-artifacts-4CZFB7WI.ts.map} +0 -0
- /package/dist/chunks/{file-selection-WZX7HAY2.ts.map → file-selection-UNHBVVON.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts.map → manager-result-dispatcher-HVRTPEVK.ts.map} +0 -0
- /package/dist/chunks/{merge-baseline-store-EVQAAML6.ts.map → merge-baseline-store-ICXVTCML.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts.map → remote-selection-ui-H6AEVA3Q.ts.map} +0 -0
- /package/dist/chunks/{s3-backend-AQGV6LFT.ts.map → s3-backend-Y4P3O5B5.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-KTUWL6DE.ts.map → setup-switch-ZMW5C4M7.ts.map} +0 -0
- /package/dist/chunks/{setup-wizard-GSDPDFC4.ts.map → setup-wizard-VW7F3JN4.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-K45QVLHB.ts.map → sync-inspection-3EMS2J5B.ts.map} +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
type StartupStage =
|
|
5
|
+
| "transaction recovery"
|
|
6
|
+
| "settings read"
|
|
7
|
+
| "config resolution"
|
|
8
|
+
| "inspection import"
|
|
9
|
+
| "inspection and guards"
|
|
10
|
+
| "backend initialization"
|
|
11
|
+
| "local snapshot"
|
|
12
|
+
| "sync state read"
|
|
13
|
+
| "remote head"
|
|
14
|
+
| "operations import"
|
|
15
|
+
| "automatic transfer"
|
|
16
|
+
| "transfer authorization"
|
|
17
|
+
| "transfer backend initialization"
|
|
18
|
+
| "transfer journal read"
|
|
19
|
+
| "transfer journal recovery"
|
|
20
|
+
| "transfer state read"
|
|
21
|
+
| "transfer local snapshot"
|
|
22
|
+
| "transfer remote head"
|
|
23
|
+
| "transfer snapshot download"
|
|
24
|
+
| "transfer unchanged verification"
|
|
25
|
+
| "transfer merge planning"
|
|
26
|
+
| "transfer settings merge"
|
|
27
|
+
| "transfer content merge"
|
|
28
|
+
| "transfer preflight"
|
|
29
|
+
| "transfer backup"
|
|
30
|
+
| "transfer journal write"
|
|
31
|
+
| "transfer remote publication"
|
|
32
|
+
| "transfer local apply"
|
|
33
|
+
| "transfer baseline write"
|
|
34
|
+
| "transfer cleanup";
|
|
35
|
+
|
|
36
|
+
/** Fixed labels and durations only: never retain errors, paths, settings, or payloads. */
|
|
37
|
+
export function createStartupTimings() {
|
|
38
|
+
const started = performance.now();
|
|
39
|
+
const stages = new Map<StartupStage, { ms: number; calls: number; interrupted: boolean }>();
|
|
40
|
+
let unchanged = false;
|
|
41
|
+
return {
|
|
42
|
+
markUnchanged() {
|
|
43
|
+
unchanged = true;
|
|
44
|
+
},
|
|
45
|
+
async measure<T>(stage: StartupStage, run: () => T | Promise<T>): Promise<T> {
|
|
46
|
+
const start = performance.now();
|
|
47
|
+
let completed = false;
|
|
48
|
+
try {
|
|
49
|
+
const result = await run();
|
|
50
|
+
completed = true;
|
|
51
|
+
return result;
|
|
52
|
+
} finally {
|
|
53
|
+
const previous = stages.get(stage);
|
|
54
|
+
stages.set(stage, {
|
|
55
|
+
ms: (previous?.ms ?? 0) + performance.now() - start,
|
|
56
|
+
calls: (previous?.calls ?? 0) + 1,
|
|
57
|
+
interrupted: Boolean(previous?.interrupted || !completed),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
publish(
|
|
62
|
+
ctx: ExtensionContext,
|
|
63
|
+
enabled: boolean,
|
|
64
|
+
sessionSignal: AbortSignal,
|
|
65
|
+
scope: "local initialization" | "background startup",
|
|
66
|
+
outcome: "completed" | "stopped" = "completed",
|
|
67
|
+
) {
|
|
68
|
+
if (!enabled || !ctx.hasUI || sessionSignal.aborted) return;
|
|
69
|
+
try {
|
|
70
|
+
ctx.ui.notify(
|
|
71
|
+
[
|
|
72
|
+
`pi-sync debug: ${scope} (${outcome})`,
|
|
73
|
+
...Array.from(
|
|
74
|
+
stages,
|
|
75
|
+
([stage, value]) =>
|
|
76
|
+
`${stage}: ${value.ms.toFixed(1)} ms${value.calls > 1 ? ` (${value.calls} calls)` : ""}${value.interrupted ? " (interrupted)" : ""}`,
|
|
77
|
+
),
|
|
78
|
+
...(unchanged ? ["transfer result: unchanged (fast path)"] : []),
|
|
79
|
+
`total: ${(performance.now() - started).toFixed(1)} ms`,
|
|
80
|
+
].join("\n"),
|
|
81
|
+
"info",
|
|
82
|
+
);
|
|
83
|
+
} catch {
|
|
84
|
+
// Diagnostics must not change sync behavior if the UI is unavailable during teardown.
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type StartupTimings = ReturnType<typeof createStartupTimings>;
|
|
@@ -4,6 +4,7 @@ import { createSnapshot } from "../snapshot/snapshot.js";
|
|
|
4
4
|
import type { SnapshotOptions } from "../snapshot/snapshot-types.js";
|
|
5
5
|
import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
|
|
6
6
|
import { throwIfAborted } from "./signals.js";
|
|
7
|
+
import { createStartupTimings, type StartupTimings } from "./startup-timings.js";
|
|
7
8
|
import { inspectRemoteSelection } from "./sync-policy.js";
|
|
8
9
|
import { hasLocalChanges, remoteChangedSinceState } from "./sync-state.js";
|
|
9
10
|
|
|
@@ -13,15 +14,18 @@ export async function inspectSync(
|
|
|
13
14
|
options: SnapshotOptions,
|
|
14
15
|
signal?: AbortSignal,
|
|
15
16
|
factory: SyncBackendFactory = createSyncBackend,
|
|
17
|
+
timings: StartupTimings = createStartupTimings(),
|
|
16
18
|
) {
|
|
17
19
|
throwIfAborted(signal);
|
|
18
|
-
const backend = await factory(config);
|
|
20
|
+
const backend = await timings.measure("backend initialization", () => factory(config));
|
|
19
21
|
throwIfAborted(signal);
|
|
20
|
-
const local = await
|
|
22
|
+
const local = await timings.measure("local snapshot", () =>
|
|
23
|
+
createSnapshot(config.snapshotIdentity, { ...options, signal }),
|
|
24
|
+
);
|
|
21
25
|
throwIfAborted(signal);
|
|
22
|
-
const state = await readStateForConfig(config);
|
|
26
|
+
const state = await timings.measure("sync state read", () => readStateForConfig(config));
|
|
23
27
|
throwIfAborted(signal);
|
|
24
|
-
const head = await backend.readHead(signal);
|
|
28
|
+
const head = await timings.measure("remote head", () => backend.readHead(signal));
|
|
25
29
|
throwIfAborted(signal);
|
|
26
30
|
return {
|
|
27
31
|
head,
|
|
@@ -23,7 +23,7 @@ import { applySnapshot } from "../snapshot/snapshot-apply.js";
|
|
|
23
23
|
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
24
24
|
import { pruneMergeBaselines } from "../state/merge-baseline-store.js";
|
|
25
25
|
import type { SyncState } from "../state/state-types.js";
|
|
26
|
-
import { readStateForConfig, writeStateForConfig } from "../state/sync-state-store.js";
|
|
26
|
+
import { readStateForConfig, syncStateFingerprint, writeStateForConfig } from "../state/sync-state-store.js";
|
|
27
27
|
import {
|
|
28
28
|
countPreservedRemoteFiles,
|
|
29
29
|
formatPullSummary,
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
formatRollbackSummary,
|
|
32
32
|
} from "../ui/sync-format.js";
|
|
33
33
|
import { setSyncStatus } from "../ui/sync-status.js";
|
|
34
|
+
import { confirmTransferReview } from "../ui/transfer-review.js";
|
|
35
|
+
import { inspectCaseCleanup, revalidateCaseCleanup } from "./apply-review.js";
|
|
34
36
|
import { confirmFieldMigration } from "./field-migration.js";
|
|
35
37
|
import { overlayLocalFields, portableSnapshot, sameLocalFields, validatePortableSnapshot } from "./local-fields.js";
|
|
36
38
|
import { readMergeJournal, requireNoMergeJournal, retireMergeJournal } from "./merge-journal.js";
|
|
@@ -155,7 +157,7 @@ export async function push(
|
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
if (!remoteForUpload && head && config.localFields !== undefined)
|
|
160
|
+
if (!remoteForUpload && head && (config.localFields !== undefined || !options.yes))
|
|
159
161
|
remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
|
|
160
162
|
if (!(await confirmFieldMigration(ctx, config, state, remoteForUpload, options.force, options.signal))) {
|
|
161
163
|
setSyncStatus(ctx, undefined);
|
|
@@ -208,12 +210,15 @@ export async function push(
|
|
|
208
210
|
if (configIdentity !== syncCheckConfigFingerprint(await loadConfig(options.setup)))
|
|
209
211
|
throw new Error("Settings changed during push review.");
|
|
210
212
|
validateMutation();
|
|
211
|
-
if (config.localFields !== undefined || (await readMergeJournal(config))) {
|
|
213
|
+
if (!options.yes || config.localFields !== undefined || (await readMergeJournal(config))) {
|
|
212
214
|
const current = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
|
|
213
215
|
if (!sameHashes(fileHashMap(current), fileHashMap(localRaw)))
|
|
214
|
-
throw new Error("Local content changed during
|
|
216
|
+
throw new Error("Local content changed during push review; retry from current bytes.");
|
|
215
217
|
}
|
|
216
|
-
|
|
218
|
+
const currentState = await readStateForConfig(config);
|
|
219
|
+
if (!options.yes && syncStateFingerprint(currentState) !== syncStateFingerprint(state))
|
|
220
|
+
throw new Error("Baseline changed during push review.");
|
|
221
|
+
requireResolvedDirectionalProgress(currentState, options);
|
|
217
222
|
validateMutation();
|
|
218
223
|
const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
|
|
219
224
|
signal: options.signal,
|
|
@@ -316,11 +321,23 @@ export async function pull(
|
|
|
316
321
|
});
|
|
317
322
|
}
|
|
318
323
|
|
|
324
|
+
const caseCleanup = options.yes ? [] : await inspectCaseCleanup(physical, options.signal);
|
|
325
|
+
validateMutation();
|
|
319
326
|
if (
|
|
320
327
|
!options.yes &&
|
|
321
|
-
!(await
|
|
328
|
+
!(await confirmTransferReview(
|
|
329
|
+
ctx,
|
|
322
330
|
snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
|
|
323
|
-
formatPullSummary(
|
|
331
|
+
formatPullSummary(
|
|
332
|
+
config,
|
|
333
|
+
backend.destination,
|
|
334
|
+
local,
|
|
335
|
+
physical,
|
|
336
|
+
protectedSessionPaths(ctx),
|
|
337
|
+
caseCleanup.map((item) => item.path),
|
|
338
|
+
),
|
|
339
|
+
options.signal,
|
|
340
|
+
validateMutation,
|
|
324
341
|
))
|
|
325
342
|
) {
|
|
326
343
|
setSyncStatus(ctx, undefined);
|
|
@@ -353,7 +370,11 @@ export async function pull(
|
|
|
353
370
|
const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
|
|
354
371
|
const applySessionDir = await sessionDirForApply(ctx, physical);
|
|
355
372
|
if (recoveringMerge) await requireFreshRecovery();
|
|
356
|
-
|
|
373
|
+
const currentState = await readStateForConfig(config);
|
|
374
|
+
if (!options.yes && syncStateFingerprint(currentState) !== syncStateFingerprint(state))
|
|
375
|
+
throw new Error("Baseline changed during pull review.");
|
|
376
|
+
requireResolvedDirectionalProgress(currentState, options);
|
|
377
|
+
if (!options.yes) await revalidateCaseCleanup(caseCleanup, physical, options.signal);
|
|
357
378
|
validateMutation();
|
|
358
379
|
options.onCommit?.();
|
|
359
380
|
const lastFileHashes = await applySnapshot(physical, protectedSessionPaths(ctx), {
|
|
@@ -573,11 +594,21 @@ export async function rollback(
|
|
|
573
594
|
if (!(await confirmFieldMigration(ctx, config, state, currentRemote, true, options.signal))) return;
|
|
574
595
|
validateMutation();
|
|
575
596
|
const physical = overlayLocalFields(remote, local, config.localFields);
|
|
597
|
+
const caseCleanup = options.yes ? [] : await inspectCaseCleanup(physical, options.signal);
|
|
598
|
+
validateMutation();
|
|
576
599
|
|
|
577
600
|
if (!options.yes) {
|
|
578
601
|
const confirmed = await ctx.ui.confirm(
|
|
579
602
|
snapshotIncludesSessions(remote) ? "Rollback pi settings and sessions?" : "Rollback pi settings?",
|
|
580
|
-
formatRollbackSummary(
|
|
603
|
+
formatRollbackSummary(
|
|
604
|
+
config,
|
|
605
|
+
backend.destination,
|
|
606
|
+
local,
|
|
607
|
+
physical,
|
|
608
|
+
target,
|
|
609
|
+
protectedSessionPaths(ctx),
|
|
610
|
+
caseCleanup.map((item) => item.path),
|
|
611
|
+
),
|
|
581
612
|
{ signal: options.signal },
|
|
582
613
|
);
|
|
583
614
|
validateMutation();
|
|
@@ -599,7 +630,11 @@ export async function rollback(
|
|
|
599
630
|
throwIfAborted(options.signal);
|
|
600
631
|
const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
|
|
601
632
|
const applySessionDir = await sessionDirForApply(ctx, remote);
|
|
602
|
-
|
|
633
|
+
const currentState = await readStateForConfig(config);
|
|
634
|
+
if (!options.yes && syncStateFingerprint(currentState) !== syncStateFingerprint(state))
|
|
635
|
+
throw new Error("Baseline changed during rollback review.");
|
|
636
|
+
requireResolvedDirectionalProgress(currentState, options, false);
|
|
637
|
+
if (!options.yes) await revalidateCaseCleanup(caseCleanup, physical, options.signal);
|
|
603
638
|
validateMutation();
|
|
604
639
|
options.onCommit?.();
|
|
605
640
|
const lastFileHashes = await applySnapshot(physical, protectedSessionPaths(ctx), {
|
|
@@ -724,9 +759,12 @@ async function confirmPush(
|
|
|
724
759
|
) {
|
|
725
760
|
throwIfAborted(options.signal);
|
|
726
761
|
if (options.yes) return true;
|
|
727
|
-
const confirmed = await
|
|
762
|
+
const confirmed = await confirmTransferReview(
|
|
763
|
+
ctx,
|
|
728
764
|
title,
|
|
729
765
|
formatPushSummary(config, backend.destination, upload, head, countPreservedRemoteFiles(local, upload), remote),
|
|
766
|
+
options.signal,
|
|
767
|
+
captureMutationOwner(ctx, options.signal),
|
|
730
768
|
);
|
|
731
769
|
throwIfAborted(options.signal);
|
|
732
770
|
if (confirmed) return true;
|
package/src/sync/sync-queries.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
|
|
|
7
7
|
import { createSnapshot, scanSnapshot } from "../snapshot/snapshot.js";
|
|
8
8
|
import type { SnapshotOptions } from "../snapshot/snapshot-types.js";
|
|
9
9
|
import { inspectLock, isLockGuardHeld, isStaleLock, withLock } from "../state/lock.js";
|
|
10
|
+
import { readStateForConfig } from "../state/sync-state-store.js";
|
|
10
11
|
import { formatDiff, formatSnapshotOnlyDiff, publicationCapabilityDescription } from "../ui/sync-format.js";
|
|
11
12
|
import { setSyncStatus } from "../ui/sync-status.js";
|
|
12
13
|
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
@@ -185,8 +186,15 @@ export async function history(
|
|
|
185
186
|
const backend = await factory(config);
|
|
186
187
|
const snapshots = (await backend.listHistory(options.signal)).slice(-20).reverse();
|
|
187
188
|
throwIfAborted(options.signal);
|
|
189
|
+
const lastApplied = await readStateForConfig(config)
|
|
190
|
+
.then((state) => state.lastAppliedSnapshot)
|
|
191
|
+
.catch(() => undefined);
|
|
192
|
+
throwIfAborted(options.signal);
|
|
193
|
+
const appliedDetails = lastApplied
|
|
194
|
+
? `Last applied snapshot ID: ${safeTerminalText(lastApplied)}\nApplication time: unavailable. Snapshot dates are creation times.`
|
|
195
|
+
: "Last applied snapshot ID: unavailable.";
|
|
188
196
|
if (snapshots.length === 0) {
|
|
189
|
-
ctx.ui.notify(
|
|
197
|
+
ctx.ui.notify(`No remote pi-sync history found.\n${appliedDetails}`, "info");
|
|
190
198
|
return;
|
|
191
199
|
}
|
|
192
200
|
|
|
@@ -197,7 +205,7 @@ export async function history(
|
|
|
197
205
|
`${index + 1}. ${item.createdAt} · ${safeTerminalText(item.machine)} · ${item.snapshotId}${item.snapshotId === currentSnapshot ? " (current)" : ""}${item.syncSessions ? " · sessions" : ""}`,
|
|
198
206
|
);
|
|
199
207
|
const selected = await ctx.ui.select(
|
|
200
|
-
`History for sync setup “${safeTerminalText(config.setupName)}”\n\nChoose a snapshot to preview rollback.`,
|
|
208
|
+
`History for sync setup “${safeTerminalText(config.setupName)}”\n\n${appliedDetails}\n\nChoose a snapshot to preview rollback.`,
|
|
201
209
|
[...labels, "Back"],
|
|
202
210
|
{ signal: options.signal },
|
|
203
211
|
);
|
|
@@ -215,7 +223,10 @@ export async function history(
|
|
|
215
223
|
return;
|
|
216
224
|
}
|
|
217
225
|
ctx.ui.notify(
|
|
218
|
-
|
|
226
|
+
[
|
|
227
|
+
appliedDetails,
|
|
228
|
+
...snapshots.map((item) => `${item.snapshotRef} ${item.createdAt} ${safeTerminalText(item.machine)}`),
|
|
229
|
+
].join("\n"),
|
|
219
230
|
"info",
|
|
220
231
|
);
|
|
221
232
|
}
|
package/src/ui/manager-state.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type SyncAttentionState,
|
|
22
22
|
syncAttentionMatchesConfig,
|
|
23
23
|
} from "./sync-attention.js";
|
|
24
|
+
import { startupTransferSummary } from "./sync-behavior.js";
|
|
24
25
|
import { countValidSyncSetups } from "./sync-setups-ui.js";
|
|
25
26
|
import { safeTerminalText } from "./terminal-text.js";
|
|
26
27
|
|
|
@@ -28,6 +29,8 @@ export const MAIN_MENU_ACTIONS = [
|
|
|
28
29
|
"Sync now",
|
|
29
30
|
"Pull from remote…",
|
|
30
31
|
"Push to remote…",
|
|
32
|
+
"View differences",
|
|
33
|
+
"Check remote status",
|
|
31
34
|
"Settings",
|
|
32
35
|
"History",
|
|
33
36
|
"Diagnostics",
|
|
@@ -121,7 +124,7 @@ export async function describeManagerState(
|
|
|
121
124
|
const lastAppliedSnapshot = changesBlocked
|
|
122
125
|
? "Unavailable while operations are locked"
|
|
123
126
|
: syncState?.lastAppliedSnapshot
|
|
124
|
-
? safeTerminalText(syncState.lastAppliedSnapshot)
|
|
127
|
+
? `${truncateToWidth(safeTerminalText(syncState.lastAppliedSnapshot), 20, "…")} (application time unavailable; full ID in History)`
|
|
125
128
|
: syncState
|
|
126
129
|
? "Never synced"
|
|
127
130
|
: "Unavailable";
|
|
@@ -134,10 +137,7 @@ export async function describeManagerState(
|
|
|
134
137
|
"",
|
|
135
138
|
`Current sync setup: ${safeTerminalText(config.setupName)}`,
|
|
136
139
|
`Storage: ${backendStorageDescription(config)}`,
|
|
137
|
-
|
|
138
|
-
`Automatic sync: ${automaticSyncSummary(config.automatic)}`,
|
|
139
|
-
`Automatic transfer at startup: ${config.automaticTransfer ? "On — upload/replace/delete at idle; no reload" : "Off"}`,
|
|
140
|
-
`Last applied: ${lastAppliedSnapshot}`,
|
|
140
|
+
"CURRENT STATE",
|
|
141
141
|
...(syncState?.unresolved?.length
|
|
142
142
|
? [
|
|
143
143
|
`Unresolved: ${syncState.unresolved.length} dependency groups · choose Review unresolved conflicts`,
|
|
@@ -160,11 +160,15 @@ export async function describeManagerState(
|
|
|
160
160
|
"Check-time observation only; synchronization rechecks current content.",
|
|
161
161
|
]
|
|
162
162
|
: ["Remote status: Not checked"]),
|
|
163
|
+
`Included: ${selection.builtIns.length} built-in group${selection.builtIns.length === 1 ? "" : "s"} · ${selection.custom.length} extra path${selection.custom.length === 1 ? "" : "s"} · Sessions ${selection.sessions ? "on" : "off"}`,
|
|
164
|
+
`Automatic sync: ${automaticSyncSummary(config.automatic)}`,
|
|
165
|
+
`Startup transfer: ${startupTransferSummary(config)}`,
|
|
166
|
+
`Last applied: ${lastAppliedSnapshot}`,
|
|
163
167
|
...(noSyncedContent
|
|
164
168
|
? ["", "No included content is selected. Choose included content in Settings before syncing."]
|
|
165
169
|
: []),
|
|
166
170
|
"",
|
|
167
|
-
"
|
|
171
|
+
"ACTIONS · transfer, inspect, or manage",
|
|
168
172
|
];
|
|
169
173
|
return {
|
|
170
174
|
title: (changesBlocked ? ["Manage sync", ...operationStatusLines(operation)] : ordinaryTitle).join("\n"),
|
package/src/ui/manager-ui.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
-
import { loadConfig } from "../settings/config.js";
|
|
3
|
+
import { loadConfig, syncCheckConfigFingerprint } from "../settings/config.js";
|
|
4
4
|
import { operationBlocksChanges } from "../state/operation-availability.js";
|
|
5
|
+
import { captureMutationOwner } from "../sync/sync-local.js";
|
|
5
6
|
import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
|
|
6
7
|
import {
|
|
7
8
|
attentionMainMenuItems,
|
|
@@ -11,12 +12,13 @@ import {
|
|
|
11
12
|
} from "./manager-attention.js";
|
|
12
13
|
import { recoverSyncAccess } from "./manager-recovery.js";
|
|
13
14
|
import { dispatchManagerResult } from "./manager-result-dispatcher.js";
|
|
14
|
-
import { describeManagerState } from "./manager-state.js";
|
|
15
|
+
import { backendStorageDescription, describeManagerState } from "./manager-state.js";
|
|
15
16
|
import { showSyncSettings } from "./settings-ui.js";
|
|
16
17
|
import { showSyncSetupManager } from "./setup/setup-actions.js";
|
|
17
18
|
import { showSetupSwitcher } from "./setup/setup-switcher.js";
|
|
18
19
|
import { showStorageConnections } from "./storage-connections-ui.js";
|
|
19
20
|
import { observationMatchesConfig } from "./sync-attention.js";
|
|
21
|
+
import { safeTerminalText } from "./terminal-text.js";
|
|
20
22
|
|
|
21
23
|
export async function showSyncManager(
|
|
22
24
|
ctx: ExtensionCommandContext,
|
|
@@ -36,6 +38,8 @@ export async function showSyncManager(
|
|
|
36
38
|
| "settings"
|
|
37
39
|
| "pull"
|
|
38
40
|
| "push"
|
|
41
|
+
| "diff"
|
|
42
|
+
| "status"
|
|
39
43
|
| "setups"
|
|
40
44
|
| "connections"
|
|
41
45
|
| "history"
|
|
@@ -167,12 +171,43 @@ export async function showSyncManager(
|
|
|
167
171
|
});
|
|
168
172
|
return { kind: result.kind === "closed" ? "close" : "stay" };
|
|
169
173
|
},
|
|
170
|
-
|
|
171
|
-
const result = await runCancellableOperation(ctx, "
|
|
174
|
+
diff: async () => {
|
|
175
|
+
const result = await runCancellableOperation(ctx, "Comparing local and remote…", "diff", runRoute, {
|
|
176
|
+
signal: sessionSignal,
|
|
177
|
+
});
|
|
178
|
+
return { kind: result.kind === "closed" ? "close" : "stay" };
|
|
179
|
+
},
|
|
180
|
+
status: async () => {
|
|
181
|
+
const result = await runCancellableOperation(ctx, "Checking remote status…", "status", runRoute, {
|
|
172
182
|
signal: sessionSignal,
|
|
173
183
|
});
|
|
174
184
|
return { kind: result.kind === "closed" ? "close" : "stay" };
|
|
175
185
|
},
|
|
186
|
+
doctor: async ({ signal: actionSignal }) => {
|
|
187
|
+
const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
|
|
188
|
+
const validate = captureMutationOwner(ctx, signal);
|
|
189
|
+
const config = await loadConfig();
|
|
190
|
+
validate();
|
|
191
|
+
const confirmed = await ctx.ui.confirm(
|
|
192
|
+
"Run diagnostics?",
|
|
193
|
+
`Setup: ${safeTerminalText(config.setupName)}\nStorage: ${backendStorageDescription(config)}\nS3/R2 reads the latest pointer without writing. Git reads remote snapshots and checks its cache. WebDAV probes conditional writes and cleanup, and can repair a missing history entry. Possible-secret checks still run. This is not the read-only Check remote status action.`,
|
|
194
|
+
{ signal },
|
|
195
|
+
);
|
|
196
|
+
if (signal.aborted) return { kind: "close" };
|
|
197
|
+
validate();
|
|
198
|
+
if (!confirmed) return { kind: "stay" };
|
|
199
|
+
const latest = await loadConfig();
|
|
200
|
+
validate();
|
|
201
|
+
if (syncCheckConfigFingerprint(config) !== syncCheckConfigFingerprint(latest)) {
|
|
202
|
+
ctx.ui.notify("Setup changed during diagnostics review; reopen Diagnostics.", "warning");
|
|
203
|
+
return { kind: "stay" };
|
|
204
|
+
}
|
|
205
|
+
const result = await runCancellableOperation(ctx, "Checking setup access…", "doctor", runRoute, {
|
|
206
|
+
signal,
|
|
207
|
+
target: config.setupName,
|
|
208
|
+
});
|
|
209
|
+
return { kind: result.kind === "closed" ? "close" : "stay" };
|
|
210
|
+
},
|
|
176
211
|
recover: async ({ state, signal: actionSignal }) => {
|
|
177
212
|
const result = await recoverSyncAccess(ctx, state.manager, runRoute, sessionSignal, actionSignal);
|
|
178
213
|
return { kind: result === "close" ? "close" : "stay" };
|
|
@@ -228,6 +263,8 @@ function syncMainMenuItem(
|
|
|
228
263
|
| "sync"
|
|
229
264
|
| "pull"
|
|
230
265
|
| "push"
|
|
266
|
+
| "diff"
|
|
267
|
+
| "status"
|
|
231
268
|
| "switch"
|
|
232
269
|
| "settings"
|
|
233
270
|
| "setups"
|
|
@@ -244,6 +281,8 @@ function syncMainMenuItem(
|
|
|
244
281
|
"Sync now": "sync",
|
|
245
282
|
"Pull from remote…": "pull",
|
|
246
283
|
"Push to remote…": "push",
|
|
284
|
+
"View differences": "diff",
|
|
285
|
+
"Check remote status": "status",
|
|
247
286
|
"Switch sync setup": "switch",
|
|
248
287
|
Settings: "settings",
|
|
249
288
|
History: "history",
|
|
@@ -263,8 +302,19 @@ function syncMainMenuItem(
|
|
|
263
302
|
id: action,
|
|
264
303
|
label,
|
|
265
304
|
action,
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
305
|
+
description:
|
|
306
|
+
action === "doctor"
|
|
307
|
+
? "Manage: review backend checks before running."
|
|
308
|
+
: action === "settings" || action === "history" || action === "switch"
|
|
309
|
+
? "Manage"
|
|
310
|
+
: action === "sync"
|
|
311
|
+
? "Transfer: choose a safe direction or review conflicts."
|
|
312
|
+
: action === "pull"
|
|
313
|
+
? "Transfer: remote → local; review writes and removals."
|
|
314
|
+
: action === "push"
|
|
315
|
+
? "Transfer: local → remote active snapshot; review publication."
|
|
316
|
+
: action === "status" || action === "diff"
|
|
317
|
+
? "Inspect: read current local and remote state without transferring files."
|
|
318
|
+
: undefined,
|
|
269
319
|
};
|
|
270
320
|
}
|
package/src/ui/merge-review.ts
CHANGED
|
@@ -60,9 +60,9 @@ export async function confirmMergeReview(
|
|
|
60
60
|
})
|
|
61
61
|
: await runMenu(ctx, menu, { getState: () => undefined, signal, isCurrent, onError: () => {} });
|
|
62
62
|
if (!isCurrent() || signal?.aborted) return false;
|
|
63
|
-
if (result.kind === "error") throw new Error("
|
|
63
|
+
if (result.kind === "error") throw new Error("Sync review failed; no transfer was performed.");
|
|
64
64
|
if (result.kind === "unsupported")
|
|
65
|
-
throw new Error("
|
|
65
|
+
throw new Error("Sync review requires observable TUI or RPC; review the plan before using --yes.");
|
|
66
66
|
if (result.kind === "completed") choice.value = result.value;
|
|
67
67
|
else if (result.kind !== "closed") return false;
|
|
68
68
|
// Keep authorization separate from UI termination in both adapters.
|
|
@@ -79,7 +79,7 @@ export async function confirmMergeReview(
|
|
|
79
79
|
onError: () => {},
|
|
80
80
|
});
|
|
81
81
|
if (!isCurrent() || signal?.aborted) return false;
|
|
82
|
-
if (detail.kind === "error") throw new Error("
|
|
82
|
+
if (detail.kind === "error") throw new Error("Sync review failed; no transfer was performed.");
|
|
83
83
|
if (detail.kind !== "cancelled" || detail.reason !== "back") return false;
|
|
84
84
|
// A fresh menu discards remembered selection: returning from details always selects No.
|
|
85
85
|
// Unlike Pi's native Yes-first dialog, approval requires moving off the safe default.
|
|
@@ -2,13 +2,8 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { PublicationCapability } from "../backends/sync-backend.js";
|
|
3
3
|
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
4
4
|
import type { FileMergeDecision } from "../sync/file-merge-planner.js";
|
|
5
|
-
import { fileHashMap } from "../sync/sync-state.js";
|
|
6
5
|
import { safeTerminalText, snapshotPathLabel } from "./terminal-text.js";
|
|
7
|
-
|
|
8
|
-
interface FileChange {
|
|
9
|
-
path: string;
|
|
10
|
-
action: "Add" | "Update" | "Delete";
|
|
11
|
-
}
|
|
6
|
+
import { transferChanges as changes, type TransferChange as FileChange } from "./transfer-changes.js";
|
|
12
7
|
|
|
13
8
|
export interface MergedSyncSummaryInput {
|
|
14
9
|
setupName: string;
|
|
@@ -78,6 +73,7 @@ export function mergedSyncSummary(input: MergedSyncSummaryInput) {
|
|
|
78
73
|
const context = [`Setup: ${safeTerminalText(input.setupName)}`, `Remote: ${safeTerminalText(input.destination)}`];
|
|
79
74
|
const safety = [
|
|
80
75
|
`Remote snapshot publication: ${input.publish ? "yes" : "no"}`,
|
|
76
|
+
"Remote removals affect the active snapshot; retained history is not erased.",
|
|
81
77
|
`Sessions: ${input.sessions ? "included — may contain private conversations" : "not included"}`,
|
|
82
78
|
input.groups.length
|
|
83
79
|
? `Unresolved groups: ${input.groups.length} — these paths stay unchanged on each side`
|
|
@@ -114,15 +110,3 @@ export function mergedSyncSummary(input: MergedSyncSummaryInput) {
|
|
|
114
110
|
].join("\n"),
|
|
115
111
|
};
|
|
116
112
|
}
|
|
117
|
-
|
|
118
|
-
function changes(before: Snapshot, after: Snapshot): FileChange[] {
|
|
119
|
-
const beforeMap = fileHashMap(before);
|
|
120
|
-
const afterMap = fileHashMap(after);
|
|
121
|
-
return [...new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)])]
|
|
122
|
-
.sort()
|
|
123
|
-
.flatMap((filePath): FileChange[] => {
|
|
124
|
-
if (!Object.hasOwn(beforeMap, filePath)) return [{ path: filePath, action: "Add" }];
|
|
125
|
-
if (!Object.hasOwn(afterMap, filePath)) return [{ path: filePath, action: "Delete" }];
|
|
126
|
-
return beforeMap[filePath] !== afterMap[filePath] ? [{ path: filePath, action: "Update" }] : [];
|
|
127
|
-
});
|
|
128
|
-
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
/** Adapt only public rendering/input boundaries; Kit still owns search, saves and focus. */
|
|
5
|
+
export function boundedSettingsUI(ctx: ExtensionCommandContext): ExtensionCommandContext["ui"] {
|
|
6
|
+
return {
|
|
7
|
+
...ctx.ui,
|
|
8
|
+
custom: (factory, options) =>
|
|
9
|
+
ctx.ui.custom((tui, ...args) => {
|
|
10
|
+
// Kit pins search before the selected setting at a one-row budget. Render its
|
|
11
|
+
// smallest complete frame, then retain the active row when the real budget is smaller.
|
|
12
|
+
const terminal = new Proxy(tui.terminal, {
|
|
13
|
+
get: (target, key) => (key === "rows" ? Math.max(8, target.rows) : Reflect.get(target, key)),
|
|
14
|
+
});
|
|
15
|
+
const screenTui = new Proxy(tui, {
|
|
16
|
+
get(target, key) {
|
|
17
|
+
if (key === "terminal") return terminal;
|
|
18
|
+
const value = Reflect.get(target, key);
|
|
19
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
const guard = <
|
|
23
|
+
T extends {
|
|
24
|
+
render(width: number): string[];
|
|
25
|
+
handleMouse?: import("@earendil-works/pi-tui").Component["handleMouse"];
|
|
26
|
+
},
|
|
27
|
+
>(
|
|
28
|
+
component: T,
|
|
29
|
+
): T => {
|
|
30
|
+
const render = component.render.bind(component);
|
|
31
|
+
const handleMouse = component.handleMouse?.bind(component);
|
|
32
|
+
let sourceRow = 0;
|
|
33
|
+
const compact = () => tui.terminal.rows < 8;
|
|
34
|
+
component.render = (width) => {
|
|
35
|
+
if (width <= 0) return [];
|
|
36
|
+
const lines = render(width);
|
|
37
|
+
if (!compact()) return lines;
|
|
38
|
+
const active = lines.findIndex((line) => stripVTControlCharacters(line).startsWith("→"));
|
|
39
|
+
const search = lines.findIndex((line) => stripVTControlCharacters(line).startsWith(">"));
|
|
40
|
+
sourceRow = active < 0 ? Math.max(0, search) : active;
|
|
41
|
+
return lines.slice(sourceRow, sourceRow + 1);
|
|
42
|
+
};
|
|
43
|
+
if (handleMouse)
|
|
44
|
+
component.handleMouse = (event) => {
|
|
45
|
+
if (event.width <= 0 || (compact() && event.y !== 0)) return undefined;
|
|
46
|
+
component.render(event.width);
|
|
47
|
+
return handleMouse(compact() ? { ...event, y: event.y + sourceRow } : event);
|
|
48
|
+
};
|
|
49
|
+
return component;
|
|
50
|
+
};
|
|
51
|
+
const component = factory(screenTui, ...args);
|
|
52
|
+
return component instanceof Promise ? component.then(guard) : guard(component);
|
|
53
|
+
}, options),
|
|
54
|
+
};
|
|
55
|
+
}
|