@narumitw/pi-sync 0.49.5 → 0.49.8
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 +58 -28
- package/package.json +2 -2
- package/src/cancellable-operation.ts +8 -3
- package/src/config.ts +4 -0
- package/src/index.ts +1 -1
- package/src/manager-attention.ts +81 -0
- package/src/manager-result-dispatcher.ts +93 -0
- package/src/manager-state.ts +50 -3
- package/src/manager-ui.ts +70 -53
- package/src/remote-selection-ui.ts +541 -142
- package/src/remote-snapshot.ts +3 -2
- package/src/settings-management.ts +9 -2
- package/src/settings-ui.ts +21 -5
- package/src/setup-switch.ts +3 -2
- package/src/snapshot-paths.ts +13 -0
- package/src/snapshot-transaction.ts +1 -1
- package/src/snapshot.ts +2 -12
- package/src/sync-attention.ts +100 -0
- package/src/sync-decision.ts +5 -13
- package/src/sync-errors.ts +23 -0
- package/src/sync-extension.ts +765 -0
- package/src/sync-format.ts +3 -4
- package/src/sync-operations.ts +4 -2
- package/src/sync-policy.ts +63 -4
- package/src/sync-resolution-ui.ts +19 -4
- package/src/sync.ts +1 -512
package/README.md
CHANGED
|
@@ -54,22 +54,36 @@ Remote status: Not checked
|
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Primary actions include **Sync now**, **Switch sync setup**, **Status & changes**, **Settings**, and
|
|
57
|
-
**More…**.
|
|
58
|
-
|
|
57
|
+
**More…**. When pi-sync has already detected a synced-content-list mismatch, the manager instead
|
|
58
|
+
shows **Sync status: Review needed**, puts **Review synced content (recommended)** first, and keeps
|
|
59
|
+
**Sync now** visibly unavailable until the choice is reviewed. Opening the manager still performs no
|
|
60
|
+
new remote request. The standard manager also owns setup and connection lists/details plus history
|
|
61
|
+
and recovery. Secondary screens provide **Back**; Escape goes back and Ctrl+C closes the flow.
|
|
59
62
|
Destructive, credential-bearing, and externally visible operations retain exact specialized previews
|
|
60
63
|
and confirmations.
|
|
61
64
|
|
|
62
65
|
### Resolve conflicts in the manager
|
|
63
66
|
|
|
64
|
-
When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human
|
|
65
|
-
|
|
66
|
-
error. The flow names the current sync setup and explains whether local content, remote content, or
|
|
67
|
-
the included-content policy changed. If the active remote snapshot explicitly selects different
|
|
68
|
-
content, automatic sync and pulls pause until you adopt the remote policy in Settings or publish the
|
|
69
|
-
local policy with a reviewed force push.
|
|
67
|
+
When **Sync now**, **Pull from remote…**, or **Push to remote…** finds changes that require a human direction choice, the manager opens **Resolve sync conflict** instead of ending at a command-only error.
|
|
68
|
+
The flow names the current sync setup and explains whether local content, remote content, or the included-content policy changed.
|
|
70
69
|
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
An explicit remote content-list mismatch now opens **Synced content differs** inside the same manager flow.
|
|
71
|
+
Nothing changes until you choose an action.
|
|
72
|
+
Choose **Review all paths (recommended)** first to compare exact remote-only paths, device-only paths, and both ordered lists.
|
|
73
|
+
If membership matches but order differs, the review says that only ordering differs.
|
|
74
|
+
Then choose one action:
|
|
75
|
+
|
|
76
|
+
- **Use remote content list** revalidates the remote snapshot and reviewed local setup before saving only `sync.include`.
|
|
77
|
+
**Remote content list saved** confirms that no files were pulled and offers a separate **Continue Sync/Pull/Push now…** action plus **Done**.
|
|
78
|
+
Continue starts a fresh operation and exact preview for the captured setup rather than resuming stale work.
|
|
79
|
+
- **Keep this device's content list and update remote…** opens the existing `push --force` preparation and exact confirmation without `--yes`.
|
|
80
|
+
Cancelling preparation or confirmation returns to the content-list choice without changing remote data.
|
|
81
|
+
- **Cancel** returns to the manager without changing settings, files, remote data, or sync state.
|
|
82
|
+
- **Later** appears when automatic startup sync detected the mismatch and returns immediately to the
|
|
83
|
+
Pi editor without changing anything.
|
|
84
|
+
|
|
85
|
+
Choose **Review differences (recommended)** in an ordinary file-direction conflict to inspect the exact affected paths without changing local files, remote data, or sync state.
|
|
86
|
+
Then choose one reviewed direction:
|
|
73
87
|
|
|
74
88
|
- **Keep local content and replace remote…** uses the existing forced-push path. It scans managed
|
|
75
89
|
local files for secrets, shows the exact remote publication effect, re-reads a changed remote head,
|
|
@@ -79,10 +93,22 @@ local files, remote data, or sync state. Then choose one reviewed direction:
|
|
|
79
93
|
- First sync uses **Use local as initial source…** and **Use remote as initial source…** labels. An
|
|
80
94
|
empty remote offers **Push local content…** only.
|
|
81
95
|
|
|
82
|
-
Cancelling a preparation or confirmation returns to conflict resolution with no side effects.
|
|
83
|
-
returns to the sync manager
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
Cancelling a preparation or confirmation returns to conflict resolution with no side effects.
|
|
97
|
+
Back returns to the sync manager, and Ctrl+C closes the complete flow.
|
|
98
|
+
|
|
99
|
+
When automatic startup sync detects an explicit content-list mismatch in TUI mode, it opens
|
|
100
|
+
**Synced content differs** once for that session instead of ending at a warning. Choosing **Later** or
|
|
101
|
+
closing the flow leaves a compact **Pi Sync needs review** widget above the editor and a
|
|
102
|
+
**review needed** footer status. The attention state is in memory only, contains no credentials or
|
|
103
|
+
file contents, and is revalidated before any settings or remote mutation. It clears after verified
|
|
104
|
+
resolution, invalidation, session replacement, or shutdown.
|
|
105
|
+
|
|
106
|
+
Interactive TUI `/sync sync`, `/sync pull`, and `/sync push` routes without `--yes` open the same
|
|
107
|
+
review flow when they detect the mismatch. Explicit `--yes` routes remain non-interactive and report
|
|
108
|
+
exact remote-only, device-only, or order-only guidance while leaving visible attention for later
|
|
109
|
+
review. Shutdown automatic sync never opens a dialog because Pi is exiting.
|
|
110
|
+
RPC startup and direct routes remain read-only and notification-based, while print and JSON modes
|
|
111
|
+
remain unsupported for `/sync` because their UI output is not observable.
|
|
86
112
|
|
|
87
113
|
## ⚙️ Settings
|
|
88
114
|
|
|
@@ -203,17 +229,16 @@ remote snapshot. Leaving the editor opens an exact Include/Exclude review with *
|
|
|
203
229
|
the draft and Discard/cancellation preserves the settings bytes. RPC remains a read-only summary with
|
|
204
230
|
the manual `sync.include` path.
|
|
205
231
|
|
|
206
|
-
Every new snapshot stores the normalized included-content selection separately from the files that
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
the
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
than silently expanding local scope. Status reports matches and exact local-only/remote-only paths.
|
|
232
|
+
Every new snapshot stores the normalized included-content selection separately from the files that happened to exist.
|
|
233
|
+
This preserves selected-but-missing paths without syncing `pi-sync.json`, storage credentials, automatic-sync preferences, or setup names.
|
|
234
|
+
**Settings → Compare synced content** opens the same review-first flow as a manager operation when local and remote lists differ.
|
|
235
|
+
Adoption revalidates the remote head, immutable snapshot, reviewed storage coordinates, and local include list before one atomic settings update.
|
|
236
|
+
The saved state changes only `sync.include`, preserves unknown settings fields, and never pulls files or writes sync state.
|
|
237
|
+
Its explicit Continue action starts a fresh **Sync now** route, while **Done** leaves the reviewed settings change saved without implying a file operation.
|
|
238
|
+
Keeping this device's list opens the reviewed force-push path and explicitly replaces the remote policy while preserving eligible unmanaged remote files.
|
|
239
|
+
|
|
240
|
+
Automatic sync and pull, including forced pull, pause on an explicit remote-policy difference rather than silently expanding local scope.
|
|
241
|
+
Status reports matches and exact local-only/remote-only paths.
|
|
217
242
|
Old snapshots remain readable. Because they have no authoritative selection, pi-sync offers only a
|
|
218
243
|
clearly labeled read-only partial discovery from safe remote file roots; selected-but-missing and
|
|
219
244
|
preserved-unmanaged intent cannot be reconstructed. Use **Add custom path…** for any needed path.
|
|
@@ -297,7 +322,7 @@ The operational state root is `<agent-dir>/pi-sync/`. An existing installation c
|
|
|
297
322
|
|
|
298
323
|
## ♿ Conditional terminal accessibility smoke
|
|
299
324
|
|
|
300
|
-
Pi exposes terminal components rather than a semantic/ARIA tree. Release validation checks textual state, keyboard operation, Escape/Back, control escaping, and narrow rendering. Critical meaning appears in text such as `(current)`, `Warning`, `Invalid`, `Saved`, `Cancelled`, and `Applied`; color is supplementary.
|
|
325
|
+
Pi exposes terminal components rather than a semantic/ARIA tree. Release validation checks textual state, keyboard operation, Escape/Back, control escaping, and narrow rendering. Critical meaning appears in text such as `(current)`, `Review needed`, `Later`, `Warning`, `Invalid`, `Saved`, `Cancelled`, and `Applied`; color is supplementary. The attention widget is informational and does not pretend to be a clickable control.
|
|
301
326
|
|
|
302
327
|
## 🗂️ Package layout
|
|
303
328
|
|
|
@@ -305,12 +330,16 @@ Pi exposes terminal components rather than a semantic/ARIA tree. Release validat
|
|
|
305
330
|
packages/pi-sync/
|
|
306
331
|
├── src/
|
|
307
332
|
│ ├── index.ts
|
|
308
|
-
│ ├── sync.ts
|
|
333
|
+
│ ├── sync-extension.ts # Lightweight Pi entry runtime and cached lazy loaders
|
|
334
|
+
│ ├── sync.ts # Compatibility barrel for package-local helpers
|
|
335
|
+
│ ├── sync-errors.ts # Lightweight setup/decision error contracts
|
|
309
336
|
│ ├── config.ts
|
|
310
337
|
│ ├── config-file.ts
|
|
311
338
|
│ ├── state-directory.ts
|
|
312
339
|
│ ├── settings-management.ts
|
|
313
340
|
│ ├── manager-ui.ts
|
|
341
|
+
│ ├── manager-attention.ts
|
|
342
|
+
│ ├── sync-attention.ts
|
|
314
343
|
│ ├── storage-connections-ui.ts
|
|
315
344
|
│ ├── sync-setups-ui.ts
|
|
316
345
|
│ ├── file-selection.ts
|
|
@@ -322,7 +351,8 @@ packages/pi-sync/
|
|
|
322
351
|
│ ├── s3-backend.ts
|
|
323
352
|
│ ├── webdav-backend.ts
|
|
324
353
|
│ ├── git-backend.ts
|
|
325
|
-
│ ├── snapshot.ts
|
|
354
|
+
│ ├── snapshot-paths.ts # Eager recovery-safe session path helpers
|
|
355
|
+
│ ├── snapshot.ts # Loaded only for snapshot operations/session push
|
|
326
356
|
│ └── *.ts
|
|
327
357
|
├── test/
|
|
328
358
|
├── README.md
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.8",
|
|
4
4
|
"description": "Pi extension that syncs Pi configuration through Git, WebDAV, Cloudflare R2, or S3-compatible storage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"typecheck": "tsc --noEmit"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@narumitw/pi-tui-kit": "^0.
|
|
42
|
+
"@narumitw/pi-tui-kit": "^0.52.0",
|
|
43
43
|
"fast-xml-parser": "^5.10.1",
|
|
44
44
|
"proper-lockfile": "^4.1.2"
|
|
45
45
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BorderedLoader,
|
|
3
|
-
type
|
|
3
|
+
type ExtensionContext,
|
|
4
4
|
type KeybindingsManager,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
@@ -8,10 +8,12 @@ import { runCustomInteraction } from "@narumitw/pi-tui-kit";
|
|
|
8
8
|
import { safeTerminalText } from "./manager-helpers.js";
|
|
9
9
|
import type { SetupPullOutcome } from "./setup-switch.js";
|
|
10
10
|
import type { SyncDecision } from "./sync-decision.js";
|
|
11
|
+
import type { RemoteSelectionDecision } from "./sync-policy.js";
|
|
11
12
|
|
|
12
13
|
export type RunRouteResult =
|
|
13
14
|
| { kind: "completed"; outcome?: SetupPullOutcome }
|
|
14
15
|
| { kind: "decision-required"; decision: SyncDecision }
|
|
16
|
+
| { kind: "remote-selection-required"; decision: RemoteSelectionDecision }
|
|
15
17
|
| { kind: "failed" };
|
|
16
18
|
|
|
17
19
|
export type RunRoute = (
|
|
@@ -34,7 +36,7 @@ interface CancellableOperationOptions {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export async function runCancellableOperation(
|
|
37
|
-
ctx:
|
|
39
|
+
ctx: ExtensionContext,
|
|
38
40
|
message: string,
|
|
39
41
|
route: string,
|
|
40
42
|
runRoute: RunRoute,
|
|
@@ -51,7 +53,10 @@ export async function runCancellableOperation(
|
|
|
51
53
|
}
|
|
52
54
|
let commitStarted = false;
|
|
53
55
|
let routeResult: RunRouteResult | undefined;
|
|
54
|
-
const interaction = await runCustomInteraction<
|
|
56
|
+
const interaction = await runCustomInteraction<
|
|
57
|
+
{ cancelled?: boolean; error?: unknown },
|
|
58
|
+
ExtensionContext
|
|
59
|
+
>(ctx, {
|
|
55
60
|
signal,
|
|
56
61
|
isCurrent: () => !signal?.aborted,
|
|
57
62
|
create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => {
|
package/src/config.ts
CHANGED
|
@@ -142,6 +142,10 @@ export function syncConfigReviewIdentity(config: AnySyncConfig) {
|
|
|
142
142
|
]);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
export function syncConfigReviewFingerprint(config: AnySyncConfig) {
|
|
146
|
+
return createHash("sha256").update(syncConfigReviewIdentity(config)).digest("hex");
|
|
147
|
+
}
|
|
148
|
+
|
|
145
149
|
function storageReviewFromConfig(config: AnySyncConfig): SyncSetupStorageReview {
|
|
146
150
|
return {
|
|
147
151
|
connectionName: config.connectionName,
|
package/src/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from "./sync.js";
|
|
1
|
+
export { default } from "./sync-extension.js";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ActionMenuItem } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import type { RunRoute } from "./cancellable-operation.js";
|
|
4
|
+
import { dispatchManagerResult } from "./manager-result-dispatcher.js";
|
|
5
|
+
import type { ManagerDescription } from "./manager-state.js";
|
|
6
|
+
import type { SyncAttentionState } from "./sync-attention.js";
|
|
7
|
+
|
|
8
|
+
export interface SyncManagerAttentionOptions {
|
|
9
|
+
getAttention?: () => SyncAttentionState | undefined;
|
|
10
|
+
onSelectionResolved?: (expected: SyncAttentionState) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function attentionMainMenuItems(
|
|
14
|
+
manager: ManagerDescription,
|
|
15
|
+
): ActionMenuItem<"main" | "more" | "recovery", "review-attention">[] {
|
|
16
|
+
if (!manager.attention) return [];
|
|
17
|
+
const disabled = manager.attentionReviewDisabled === true;
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
id: "review-attention",
|
|
21
|
+
label: "Review synced content (recommended)",
|
|
22
|
+
action: "review-attention",
|
|
23
|
+
...(disabled
|
|
24
|
+
? {
|
|
25
|
+
disabled: true,
|
|
26
|
+
disabledReason: "Finish or recover the active operation first.",
|
|
27
|
+
}
|
|
28
|
+
: {}),
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function blockedSyncMenuItem(
|
|
34
|
+
label: string,
|
|
35
|
+
manager: ManagerDescription,
|
|
36
|
+
): ActionMenuItem<"main" | "more" | "recovery", "sync"> | undefined {
|
|
37
|
+
if (label !== "Sync now (recommended)" || !manager.attentionBlocksSync) return undefined;
|
|
38
|
+
return {
|
|
39
|
+
id: "sync",
|
|
40
|
+
label,
|
|
41
|
+
description: "Review first.",
|
|
42
|
+
action: "sync",
|
|
43
|
+
disabled: true,
|
|
44
|
+
disabledReason: "Review synced content first.",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function showManagerAttention(
|
|
49
|
+
ctx: ExtensionCommandContext,
|
|
50
|
+
attention: SyncAttentionState,
|
|
51
|
+
runRoute: RunRoute,
|
|
52
|
+
signal: AbortSignal | undefined,
|
|
53
|
+
onSelectionResolved: (() => void) | undefined,
|
|
54
|
+
): Promise<"close" | "stay"> {
|
|
55
|
+
const { showRemoteSelectionReview } = await import("./remote-selection-ui.js");
|
|
56
|
+
if (signal?.aborted) return "close";
|
|
57
|
+
const review = await showRemoteSelectionReview(
|
|
58
|
+
ctx,
|
|
59
|
+
attention.decision.setupName,
|
|
60
|
+
signal,
|
|
61
|
+
undefined,
|
|
62
|
+
{
|
|
63
|
+
decision: attention.decision,
|
|
64
|
+
origin: attention.origin,
|
|
65
|
+
runRoute,
|
|
66
|
+
onSelectionResolved,
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
if (review.kind === "route-result") {
|
|
70
|
+
const disposition = await dispatchManagerResult(
|
|
71
|
+
ctx,
|
|
72
|
+
review.result,
|
|
73
|
+
review.route,
|
|
74
|
+
runRoute,
|
|
75
|
+
signal,
|
|
76
|
+
{ onSelectionResolved },
|
|
77
|
+
);
|
|
78
|
+
return disposition.kind;
|
|
79
|
+
}
|
|
80
|
+
return review.kind === "closed" || review.kind === "stale" ? "close" : "stay";
|
|
81
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
CancellableOperationResult,
|
|
4
|
+
RunRoute,
|
|
5
|
+
RunRouteResult,
|
|
6
|
+
} from "./cancellable-operation.js";
|
|
7
|
+
import { type RemoteSelectionOrigin, showRemoteSelectionReview } from "./remote-selection-ui.js";
|
|
8
|
+
import { showSyncResolution } from "./sync-resolution-ui.js";
|
|
9
|
+
|
|
10
|
+
const MAX_DECISION_TRANSITIONS = 32;
|
|
11
|
+
|
|
12
|
+
export interface ManagerResultDisposition {
|
|
13
|
+
kind: "close" | "stay";
|
|
14
|
+
appliedRoute?: "sync" | "pull" | "push";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ManagerResultOptions {
|
|
18
|
+
cancelLabel?: string;
|
|
19
|
+
onSelectionResolved?: () => void;
|
|
20
|
+
withStateAccess?: <T>(task: () => Promise<T>) => Promise<T>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function dispatchManagerResult(
|
|
24
|
+
ctx: ExtensionContext,
|
|
25
|
+
initialResult: CancellableOperationResult,
|
|
26
|
+
origin: Exclude<RemoteSelectionOrigin, "settings">,
|
|
27
|
+
runRoute: RunRoute,
|
|
28
|
+
signal?: AbortSignal,
|
|
29
|
+
options: ManagerResultOptions = {},
|
|
30
|
+
): Promise<ManagerResultDisposition> {
|
|
31
|
+
let result: CancellableOperationResult | RunRouteResult = initialResult;
|
|
32
|
+
let currentRoute: "sync" | "pull" | "push" = origin;
|
|
33
|
+
let resolving = false;
|
|
34
|
+
|
|
35
|
+
for (let transition = 0; transition < MAX_DECISION_TRANSITIONS; transition += 1) {
|
|
36
|
+
if (signal?.aborted) return { kind: "close" };
|
|
37
|
+
if (result.kind === "remote-selection-required") {
|
|
38
|
+
resolving = true;
|
|
39
|
+
const resolution = await showRemoteSelectionReview(
|
|
40
|
+
ctx,
|
|
41
|
+
result.decision.setupName,
|
|
42
|
+
signal,
|
|
43
|
+
undefined,
|
|
44
|
+
{
|
|
45
|
+
decision: result.decision,
|
|
46
|
+
origin: currentRoute,
|
|
47
|
+
runRoute,
|
|
48
|
+
cancelLabel: options.cancelLabel,
|
|
49
|
+
onSelectionResolved: options.onSelectionResolved,
|
|
50
|
+
withStateAccess: options.withStateAccess,
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
if (resolution.kind === "route-result") {
|
|
54
|
+
result = resolution.result;
|
|
55
|
+
currentRoute = resolution.route;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
return resolution.kind === "closed" || resolution.kind === "stale"
|
|
59
|
+
? { kind: "close" }
|
|
60
|
+
: { kind: "stay" };
|
|
61
|
+
}
|
|
62
|
+
if (result.kind === "decision-required") {
|
|
63
|
+
resolving = true;
|
|
64
|
+
const resolution = await showSyncResolution(ctx, result.decision, runRoute, signal);
|
|
65
|
+
if (resolution.kind === "route-result") {
|
|
66
|
+
result = resolution.result;
|
|
67
|
+
currentRoute = resolution.route;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (resolution.kind === "resolved") {
|
|
71
|
+
return { kind: "close", appliedRoute: resolution.direction };
|
|
72
|
+
}
|
|
73
|
+
return resolution.kind === "closed" || resolution.kind === "stale"
|
|
74
|
+
? { kind: "close" }
|
|
75
|
+
: { kind: "stay" };
|
|
76
|
+
}
|
|
77
|
+
if (result.kind === "closed") return { kind: "close" };
|
|
78
|
+
if (result.kind === "completed") {
|
|
79
|
+
const appliedRoute = result.outcome === "applied" ? currentRoute : undefined;
|
|
80
|
+
if (resolving || (origin === "pull" && result.outcome === "applied")) {
|
|
81
|
+
return { kind: "close", ...(appliedRoute ? { appliedRoute } : {}) };
|
|
82
|
+
}
|
|
83
|
+
return { kind: "stay", ...(appliedRoute ? { appliedRoute } : {}) };
|
|
84
|
+
}
|
|
85
|
+
return { kind: "stay" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ctx.ui.notify(
|
|
89
|
+
"Sync resolution stopped after too many state changes. Start /sync again to review fresh state.",
|
|
90
|
+
"warning",
|
|
91
|
+
);
|
|
92
|
+
return { kind: "stay" };
|
|
93
|
+
}
|
package/src/manager-state.ts
CHANGED
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
} from "./config.js";
|
|
8
8
|
import { inspectLock, isStaleLock } from "./lock.js";
|
|
9
9
|
import { errorMessage, ownRecord, safeTerminalText } from "./manager-helpers.js";
|
|
10
|
-
import {
|
|
10
|
+
import { type SyncAttentionState, syncAttentionMatchesConfig } from "./sync-attention.js";
|
|
11
|
+
import { compareSyncInclude, syncIncludeSelection } from "./sync-policy.js";
|
|
11
12
|
import { countValidSyncSetups } from "./sync-setups-ui.js";
|
|
12
13
|
import type { AnySyncConfig } from "./types.js";
|
|
13
14
|
|
|
@@ -19,9 +20,18 @@ export const MAIN_MENU_ACTIONS = [
|
|
|
19
20
|
"More…",
|
|
20
21
|
] as const;
|
|
21
22
|
|
|
23
|
+
export interface ManagerDescription {
|
|
24
|
+
title: string;
|
|
25
|
+
actions: string[];
|
|
26
|
+
attention?: SyncAttentionState;
|
|
27
|
+
attentionBlocksSync?: boolean;
|
|
28
|
+
attentionReviewDisabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
export async function describeManagerState(
|
|
23
32
|
signal?: AbortSignal,
|
|
24
|
-
|
|
33
|
+
attention?: SyncAttentionState,
|
|
34
|
+
): Promise<ManagerDescription> {
|
|
25
35
|
let raw: Record<string, unknown> | undefined;
|
|
26
36
|
try {
|
|
27
37
|
raw = await readLocalConfigObject();
|
|
@@ -66,6 +76,25 @@ export async function describeManagerState(
|
|
|
66
76
|
const recoverableLock =
|
|
67
77
|
lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock));
|
|
68
78
|
const selection = syncIncludeSelection(config.include);
|
|
79
|
+
let currentAttention: SyncAttentionState | undefined;
|
|
80
|
+
if (attention) {
|
|
81
|
+
try {
|
|
82
|
+
const attentionConfig =
|
|
83
|
+
attention.decision.setupName === config.setupName
|
|
84
|
+
? config
|
|
85
|
+
: await loadConfig(attention.decision.setupName);
|
|
86
|
+
if (syncAttentionMatchesConfig(attention, attentionConfig)) currentAttention = attention;
|
|
87
|
+
} catch {
|
|
88
|
+
currentAttention = undefined;
|
|
89
|
+
}
|
|
90
|
+
if (signal?.aborted) throw signal.reason;
|
|
91
|
+
}
|
|
92
|
+
const attentionComparison = currentAttention
|
|
93
|
+
? compareSyncInclude(
|
|
94
|
+
currentAttention.decision.localInclude,
|
|
95
|
+
currentAttention.decision.remoteInclude,
|
|
96
|
+
)
|
|
97
|
+
: undefined;
|
|
69
98
|
const noSyncedContent = config.include.length === 0;
|
|
70
99
|
const syncState =
|
|
71
100
|
liveLock || recoverableLock
|
|
@@ -92,7 +121,18 @@ export async function describeManagerState(
|
|
|
92
121
|
`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"}`,
|
|
93
122
|
`Automatic sync: ${config.automatic ? "On" : "Off"}`,
|
|
94
123
|
`Last applied: ${lastAppliedSnapshot}`,
|
|
95
|
-
|
|
124
|
+
...(currentAttention
|
|
125
|
+
? [
|
|
126
|
+
currentAttention.decision.setupName === config.setupName
|
|
127
|
+
? "Sync status: Review needed"
|
|
128
|
+
: `Sync status: Review needed for setup ${safeTerminalText(currentAttention.decision.setupName)}`,
|
|
129
|
+
attentionComparison?.remoteOnly.length === 0 &&
|
|
130
|
+
attentionComparison.localOnly.length === 0
|
|
131
|
+
? "Only the synced-content order differs."
|
|
132
|
+
: `Remote-only paths: ${attentionComparison?.remoteOnly.length ?? 0} · Device-only paths: ${attentionComparison?.localOnly.length ?? 0}`,
|
|
133
|
+
"Nothing has been changed.",
|
|
134
|
+
]
|
|
135
|
+
: ["Remote status: Not checked"]),
|
|
96
136
|
...(noSyncedContent
|
|
97
137
|
? [
|
|
98
138
|
"",
|
|
@@ -117,6 +157,13 @@ export async function describeManagerState(
|
|
|
117
157
|
: noSyncedContent
|
|
118
158
|
? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
|
|
119
159
|
: mainActions,
|
|
160
|
+
...(currentAttention
|
|
161
|
+
? {
|
|
162
|
+
attention: currentAttention,
|
|
163
|
+
attentionBlocksSync: currentAttention.decision.setupName === config.setupName,
|
|
164
|
+
attentionReviewDisabled: liveLock || recoverableLock,
|
|
165
|
+
}
|
|
166
|
+
: {}),
|
|
120
167
|
};
|
|
121
168
|
} catch (error) {
|
|
122
169
|
if (signal?.aborted) throw error;
|