@narumitw/pi-sync 0.49.6 → 0.49.9
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 +77 -26
- package/package.json +2 -2
- package/src/cancellable-operation.ts +8 -3
- package/src/config.ts +4 -0
- package/src/lock.ts +26 -2
- package/src/manager-attention.ts +81 -0
- package/src/manager-recovery.ts +65 -0
- package/src/manager-result-dispatcher.ts +93 -0
- package/src/manager-state.ts +145 -52
- package/src/manager-ui.ts +130 -75
- package/src/operation-availability.ts +64 -0
- 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/sync-attention.ts +100 -0
- package/src/sync-extension.ts +203 -5
- package/src/sync-operations.ts +4 -2
- package/src/sync-policy.ts +63 -4
- package/src/sync-resolution-ui.ts +19 -4
package/src/manager-state.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
1
2
|
import {
|
|
2
3
|
activeLocalConfigPath,
|
|
3
4
|
isCloudflareR2Endpoint,
|
|
@@ -5,9 +6,15 @@ import {
|
|
|
5
6
|
readLocalConfigObject,
|
|
6
7
|
readStateForConfig,
|
|
7
8
|
} from "./config.js";
|
|
8
|
-
import { inspectLock, isStaleLock } from "./lock.js";
|
|
9
9
|
import { errorMessage, ownRecord, safeTerminalText } from "./manager-helpers.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
inspectOperationAvailability,
|
|
12
|
+
type OperationAvailability,
|
|
13
|
+
operationBlocksChanges,
|
|
14
|
+
operationCanRecover,
|
|
15
|
+
} from "./operation-availability.js";
|
|
16
|
+
import { type SyncAttentionState, syncAttentionMatchesConfig } from "./sync-attention.js";
|
|
17
|
+
import { compareSyncInclude, syncIncludeSelection } from "./sync-policy.js";
|
|
11
18
|
import { countValidSyncSetups } from "./sync-setups-ui.js";
|
|
12
19
|
import type { AnySyncConfig } from "./types.js";
|
|
13
20
|
|
|
@@ -19,9 +26,20 @@ export const MAIN_MENU_ACTIONS = [
|
|
|
19
26
|
"More…",
|
|
20
27
|
] as const;
|
|
21
28
|
|
|
29
|
+
export interface ManagerDescription {
|
|
30
|
+
title: string;
|
|
31
|
+
actions: string[];
|
|
32
|
+
operation?: OperationAvailability;
|
|
33
|
+
attention?: SyncAttentionState;
|
|
34
|
+
attentionBlocksSync?: boolean;
|
|
35
|
+
attentionReviewDisabled?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
export async function describeManagerState(
|
|
23
39
|
signal?: AbortSignal,
|
|
24
|
-
|
|
40
|
+
attention?: SyncAttentionState,
|
|
41
|
+
inspectOperation: () => Promise<OperationAvailability> = inspectOperationAvailability,
|
|
42
|
+
): Promise<ManagerDescription> {
|
|
25
43
|
let raw: Record<string, unknown> | undefined;
|
|
26
44
|
try {
|
|
27
45
|
raw = await readLocalConfigObject();
|
|
@@ -61,62 +79,86 @@ export async function describeManagerState(
|
|
|
61
79
|
}
|
|
62
80
|
try {
|
|
63
81
|
const config = await loadConfig();
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const recoverableLock =
|
|
67
|
-
lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock));
|
|
82
|
+
const operation = await inspectOperation();
|
|
83
|
+
const changesBlocked = operationBlocksChanges(operation);
|
|
68
84
|
const selection = syncIncludeSelection(config.include);
|
|
85
|
+
let currentAttention: SyncAttentionState | undefined;
|
|
86
|
+
if (attention) {
|
|
87
|
+
try {
|
|
88
|
+
const attentionConfig =
|
|
89
|
+
attention.decision.setupName === config.setupName
|
|
90
|
+
? config
|
|
91
|
+
: await loadConfig(attention.decision.setupName);
|
|
92
|
+
if (syncAttentionMatchesConfig(attention, attentionConfig)) currentAttention = attention;
|
|
93
|
+
} catch {
|
|
94
|
+
currentAttention = undefined;
|
|
95
|
+
}
|
|
96
|
+
if (signal?.aborted) throw signal.reason;
|
|
97
|
+
}
|
|
98
|
+
const attentionComparison = currentAttention
|
|
99
|
+
? compareSyncInclude(
|
|
100
|
+
currentAttention.decision.localInclude,
|
|
101
|
+
currentAttention.decision.remoteInclude,
|
|
102
|
+
)
|
|
103
|
+
: undefined;
|
|
69
104
|
const noSyncedContent = config.include.length === 0;
|
|
70
|
-
const syncState =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
?
|
|
77
|
-
: syncState
|
|
78
|
-
?
|
|
79
|
-
:
|
|
80
|
-
? "Never synced"
|
|
81
|
-
: "Unavailable";
|
|
105
|
+
const syncState = changesBlocked
|
|
106
|
+
? undefined
|
|
107
|
+
: await readStateForConfig(config).catch(() => undefined);
|
|
108
|
+
const lastAppliedSnapshot = changesBlocked
|
|
109
|
+
? "Unavailable while operations are locked"
|
|
110
|
+
: syncState?.lastAppliedSnapshot
|
|
111
|
+
? safeTerminalText(syncState.lastAppliedSnapshot)
|
|
112
|
+
: syncState
|
|
113
|
+
? "Never synced"
|
|
114
|
+
: "Unavailable";
|
|
82
115
|
const canSwitch = (await countValidSyncSetups(configuredTargets, signal)) > 1;
|
|
83
116
|
const mainActions = MAIN_MENU_ACTIONS.filter(
|
|
84
117
|
(action) => action !== "Switch sync setup" || canSwitch,
|
|
85
118
|
);
|
|
119
|
+
const ordinaryTitle = [
|
|
120
|
+
"Manage sync",
|
|
121
|
+
"",
|
|
122
|
+
`Current sync setup: ${safeTerminalText(config.setupName)}`,
|
|
123
|
+
`Storage: ${backendStorageDescription(config)}`,
|
|
124
|
+
`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"}`,
|
|
125
|
+
`Automatic sync: ${config.automatic ? "On" : "Off"}`,
|
|
126
|
+
`Last applied: ${lastAppliedSnapshot}`,
|
|
127
|
+
...(currentAttention
|
|
128
|
+
? [
|
|
129
|
+
currentAttention.decision.setupName === config.setupName
|
|
130
|
+
? "Sync status: Review needed"
|
|
131
|
+
: `Sync status: Review needed for setup ${safeTerminalText(currentAttention.decision.setupName)}`,
|
|
132
|
+
attentionComparison?.remoteOnly.length === 0 &&
|
|
133
|
+
attentionComparison.localOnly.length === 0
|
|
134
|
+
? "Only the synced-content order differs."
|
|
135
|
+
: `Remote-only paths: ${attentionComparison?.remoteOnly.length ?? 0} · Device-only paths: ${attentionComparison?.localOnly.length ?? 0}`,
|
|
136
|
+
"Nothing has been changed.",
|
|
137
|
+
]
|
|
138
|
+
: ["Remote status: Not checked"]),
|
|
139
|
+
...(noSyncedContent
|
|
140
|
+
? [
|
|
141
|
+
"",
|
|
142
|
+
"No included content is selected. Choose included content in Settings before syncing.",
|
|
143
|
+
]
|
|
144
|
+
: []),
|
|
145
|
+
"",
|
|
146
|
+
"What do you want to do?",
|
|
147
|
+
];
|
|
86
148
|
return {
|
|
87
|
-
title:
|
|
88
|
-
"Manage sync",
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
]
|
|
101
|
-
: []),
|
|
102
|
-
...(liveLock
|
|
103
|
-
? [
|
|
104
|
-
"",
|
|
105
|
-
`Operation in progress: ${safeTerminalText(lock.lock.command)} (pid ${lock.lock.pid}). Sync and settings changes are disabled.`,
|
|
106
|
-
]
|
|
107
|
-
: []),
|
|
108
|
-
...(recoverableLock
|
|
109
|
-
? ["", "Recovery required: lock metadata is stale or unreadable."]
|
|
110
|
-
: []),
|
|
111
|
-
"",
|
|
112
|
-
"What do you want to do?",
|
|
113
|
-
].join("\n"),
|
|
114
|
-
actions:
|
|
115
|
-
liveLock || recoverableLock
|
|
116
|
-
? ["Status & changes", "History & recovery…", "Help"]
|
|
117
|
-
: noSyncedContent
|
|
118
|
-
? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
|
|
119
|
-
: mainActions,
|
|
149
|
+
title: (changesBlocked
|
|
150
|
+
? ["Manage sync", ...operationStatusLines(operation)]
|
|
151
|
+
: ordinaryTitle
|
|
152
|
+
).join("\n"),
|
|
153
|
+
actions: operationActions(operation, noSyncedContent, canSwitch, mainActions),
|
|
154
|
+
operation,
|
|
155
|
+
...(currentAttention
|
|
156
|
+
? {
|
|
157
|
+
attention: currentAttention,
|
|
158
|
+
attentionBlocksSync: currentAttention.decision.setupName === config.setupName,
|
|
159
|
+
attentionReviewDisabled: changesBlocked,
|
|
160
|
+
}
|
|
161
|
+
: {}),
|
|
120
162
|
};
|
|
121
163
|
} catch (error) {
|
|
122
164
|
if (signal?.aborted) throw error;
|
|
@@ -136,6 +178,57 @@ export async function describeManagerState(
|
|
|
136
178
|
}
|
|
137
179
|
}
|
|
138
180
|
|
|
181
|
+
function operationActions(
|
|
182
|
+
operation: OperationAvailability,
|
|
183
|
+
noSyncedContent: boolean,
|
|
184
|
+
canSwitch: boolean,
|
|
185
|
+
mainActions: string[],
|
|
186
|
+
) {
|
|
187
|
+
if (operationCanRecover(operation)) {
|
|
188
|
+
return [
|
|
189
|
+
"Restore sync access… (recommended)",
|
|
190
|
+
"Status & changes",
|
|
191
|
+
"History & recovery…",
|
|
192
|
+
"Help",
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
if (operation.kind !== "free") {
|
|
196
|
+
return ["Refresh operation status", "Status & changes", "History & recovery…", "Help"];
|
|
197
|
+
}
|
|
198
|
+
return noSyncedContent
|
|
199
|
+
? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
|
|
200
|
+
: mainActions;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function operationStatusLines(operation: OperationAvailability): string[] {
|
|
204
|
+
switch (operation.kind) {
|
|
205
|
+
case "free":
|
|
206
|
+
return [];
|
|
207
|
+
case "live": {
|
|
208
|
+
const command = truncateToWidth(safeTerminalText(operation.lock.command), 16, "…");
|
|
209
|
+
return [
|
|
210
|
+
`Running: ${command} (pid ${operation.lock.pid}). Wait, then refresh; Settings and More return.`,
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
case "busy":
|
|
214
|
+
return [
|
|
215
|
+
"Pi-sync may be starting or finishing. Wait, then refresh; Settings and More remain unavailable.",
|
|
216
|
+
];
|
|
217
|
+
case "recoverable-stale":
|
|
218
|
+
return [
|
|
219
|
+
"Sync paused: old lock remains. Close other Pi sessions then restore; Settings and More return.",
|
|
220
|
+
];
|
|
221
|
+
case "recoverable-unreadable":
|
|
222
|
+
return [
|
|
223
|
+
"Sync paused: owner unknown. Close other Pi sessions then restore; Settings and More return.",
|
|
224
|
+
];
|
|
225
|
+
case "inspection-error":
|
|
226
|
+
return [
|
|
227
|
+
"Lock check failed. Fix path access, then refresh; Settings and More remain unavailable.",
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
139
232
|
export function backendStorageDescription(config: AnySyncConfig) {
|
|
140
233
|
const connection = safeTerminalText(config.connectionName);
|
|
141
234
|
switch (config.backend.type) {
|
package/src/manager-ui.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
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 {
|
|
4
|
-
type CancellableOperationResult,
|
|
5
|
-
type RunRoute,
|
|
6
|
-
runCancellableOperation,
|
|
7
|
-
} from "./cancellable-operation.js";
|
|
3
|
+
import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
|
|
8
4
|
import { setSyncSetupCompletions } from "./command.js";
|
|
9
5
|
import {
|
|
10
6
|
configuredSyncSetupNames,
|
|
@@ -17,7 +13,12 @@ import {
|
|
|
17
13
|
syncConfigReviewIdentity,
|
|
18
14
|
} from "./config.js";
|
|
19
15
|
import { showAddGitTarget, showEditGitTarget, showGitSetup } from "./git-ui.js";
|
|
20
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
attentionMainMenuItems,
|
|
18
|
+
blockedSyncMenuItem,
|
|
19
|
+
type SyncManagerAttentionOptions,
|
|
20
|
+
showManagerAttention,
|
|
21
|
+
} from "./manager-attention.js";
|
|
21
22
|
import {
|
|
22
23
|
errorMessage,
|
|
23
24
|
ownRecord,
|
|
@@ -25,7 +26,10 @@ import {
|
|
|
25
26
|
requiredInput,
|
|
26
27
|
safeTerminalText,
|
|
27
28
|
} from "./manager-helpers.js";
|
|
29
|
+
import { recoverSyncAccess } from "./manager-recovery.js";
|
|
30
|
+
import { dispatchManagerResult } from "./manager-result-dispatcher.js";
|
|
28
31
|
import { backendStorageDescription, describeManagerState } from "./manager-state.js";
|
|
32
|
+
import { operationCanRecover } from "./operation-availability.js";
|
|
29
33
|
import { chooseS3Credentials } from "./s3-credentials-ui.js";
|
|
30
34
|
import {
|
|
31
35
|
addSyncSetup,
|
|
@@ -37,7 +41,6 @@ import { showSyncSettings } from "./settings-ui.js";
|
|
|
37
41
|
import { useSyncSetup } from "./setup-switch.js";
|
|
38
42
|
import { showAddStorageConnection, showStorageConnections } from "./storage-connections-ui.js";
|
|
39
43
|
import { DEFAULT_SYNC_INCLUDE, syncIncludeSelection } from "./sync-policy.js";
|
|
40
|
-
import { showSyncResolution } from "./sync-resolution-ui.js";
|
|
41
44
|
import { showSyncSetups } from "./sync-setups-ui.js";
|
|
42
45
|
import type { AnySyncConfig } from "./types.js";
|
|
43
46
|
import { showAddWebDavTarget, showEditWebDavTarget, showWebDavSetup } from "./webdav-ui.js";
|
|
@@ -46,6 +49,7 @@ export async function showSyncManager(
|
|
|
46
49
|
ctx: ExtensionCommandContext,
|
|
47
50
|
runRoute: RunRoute,
|
|
48
51
|
sessionSignal?: AbortSignal,
|
|
52
|
+
options: SyncManagerAttentionOptions = {},
|
|
49
53
|
): Promise<void> {
|
|
50
54
|
if (!ctx.hasUI) {
|
|
51
55
|
await runRoute("help");
|
|
@@ -53,6 +57,7 @@ export async function showSyncManager(
|
|
|
53
57
|
}
|
|
54
58
|
type Screen = "main" | "more" | "recovery";
|
|
55
59
|
type Action =
|
|
60
|
+
| "review-attention"
|
|
56
61
|
| "sync"
|
|
57
62
|
| "switch"
|
|
58
63
|
| "diff"
|
|
@@ -64,23 +69,34 @@ export async function showSyncManager(
|
|
|
64
69
|
| "history"
|
|
65
70
|
| "doctor"
|
|
66
71
|
| "unlock"
|
|
72
|
+
| "recover"
|
|
73
|
+
| "refresh"
|
|
67
74
|
| "help"
|
|
68
75
|
| "init"
|
|
69
76
|
| "back";
|
|
70
77
|
interface State {
|
|
71
78
|
manager: Awaited<ReturnType<typeof describeManagerState>>;
|
|
72
|
-
canRecover: boolean;
|
|
73
79
|
}
|
|
74
80
|
const menu = defineMenu<State, Screen, Action, ExtensionCommandContext>({
|
|
75
81
|
start: "main",
|
|
76
82
|
screens: {
|
|
77
|
-
main: ({ state }) =>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
main: ({ state }) => {
|
|
84
|
+
const attentionItems = attentionMainMenuItems(state.manager);
|
|
85
|
+
const managerItems = state.manager.actions.map(
|
|
86
|
+
(label) => blockedSyncMenuItem(label, state.manager) ?? syncMainMenuItem(label),
|
|
87
|
+
);
|
|
88
|
+
const operationFirst =
|
|
89
|
+
state.manager.operation !== undefined && state.manager.operation.kind !== "free";
|
|
90
|
+
return {
|
|
91
|
+
kind: "actions",
|
|
92
|
+
title: "Manage sync",
|
|
93
|
+
lines: state.manager.title.split("\n").slice(1),
|
|
94
|
+
items: operationFirst
|
|
95
|
+
? [...managerItems, ...attentionItems]
|
|
96
|
+
: [...attentionItems, ...managerItems],
|
|
97
|
+
hint: "close",
|
|
98
|
+
};
|
|
99
|
+
},
|
|
84
100
|
more: () => ({
|
|
85
101
|
kind: "actions",
|
|
86
102
|
title: "More options",
|
|
@@ -105,7 +121,7 @@ export async function showSyncManager(
|
|
|
105
121
|
items: [
|
|
106
122
|
{ id: "history", label: "Browse history", action: "history" },
|
|
107
123
|
{ id: "doctor", label: "Check setup", action: "doctor" },
|
|
108
|
-
...(state.
|
|
124
|
+
...(state.manager.operation && operationCanRecover(state.manager.operation)
|
|
109
125
|
? [{ id: "unlock", label: "Recover stale operation", action: "unlock" as const }]
|
|
110
126
|
: []),
|
|
111
127
|
{ id: "back", label: "Back", action: "back" },
|
|
@@ -114,7 +130,28 @@ export async function showSyncManager(
|
|
|
114
130
|
}),
|
|
115
131
|
},
|
|
116
132
|
actions: {
|
|
133
|
+
"review-attention": async () => {
|
|
134
|
+
const attention = options.getAttention?.();
|
|
135
|
+
if (!attention) return { kind: "stay" };
|
|
136
|
+
const disposition = await showManagerAttention(
|
|
137
|
+
ctx,
|
|
138
|
+
attention,
|
|
139
|
+
runRoute,
|
|
140
|
+
sessionSignal,
|
|
141
|
+
() => options.onSelectionResolved?.(attention),
|
|
142
|
+
);
|
|
143
|
+
return { kind: disposition };
|
|
144
|
+
},
|
|
117
145
|
sync: async () => {
|
|
146
|
+
const pendingAttention = options.getAttention?.();
|
|
147
|
+
if (pendingAttention) {
|
|
148
|
+
const activeConfig = await loadConfig();
|
|
149
|
+
if (sessionSignal?.aborted) return { kind: "close" };
|
|
150
|
+
if (pendingAttention.decision.setupName === activeConfig.setupName) {
|
|
151
|
+
ctx.ui.notify("Review synced content before starting Sync now.", "warning");
|
|
152
|
+
return { kind: "stay" };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
118
155
|
const result = await runCancellableOperation(
|
|
119
156
|
ctx,
|
|
120
157
|
"Checking current sync setup…",
|
|
@@ -125,10 +162,14 @@ export async function showSyncManager(
|
|
|
125
162
|
signal: sessionSignal,
|
|
126
163
|
},
|
|
127
164
|
);
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
165
|
+
const disposition = await dispatchManagerResult(
|
|
166
|
+
ctx,
|
|
167
|
+
result,
|
|
168
|
+
"sync",
|
|
169
|
+
runRoute,
|
|
170
|
+
sessionSignal,
|
|
171
|
+
);
|
|
172
|
+
return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
|
|
132
173
|
},
|
|
133
174
|
switch: async () => {
|
|
134
175
|
const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
|
|
@@ -162,12 +203,14 @@ export async function showSyncManager(
|
|
|
162
203
|
signal: sessionSignal,
|
|
163
204
|
},
|
|
164
205
|
);
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
206
|
+
const disposition = await dispatchManagerResult(
|
|
207
|
+
ctx,
|
|
208
|
+
result,
|
|
209
|
+
"pull",
|
|
210
|
+
runRoute,
|
|
211
|
+
sessionSignal,
|
|
212
|
+
);
|
|
213
|
+
return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
|
|
171
214
|
},
|
|
172
215
|
push: async () => {
|
|
173
216
|
const result = await runCancellableOperation(
|
|
@@ -181,10 +224,14 @@ export async function showSyncManager(
|
|
|
181
224
|
signal: sessionSignal,
|
|
182
225
|
},
|
|
183
226
|
);
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
227
|
+
const disposition = await dispatchManagerResult(
|
|
228
|
+
ctx,
|
|
229
|
+
result,
|
|
230
|
+
"push",
|
|
231
|
+
runRoute,
|
|
232
|
+
sessionSignal,
|
|
233
|
+
);
|
|
234
|
+
return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
|
|
188
235
|
},
|
|
189
236
|
setups: async () => {
|
|
190
237
|
const result = await showSyncSetupManager(ctx, runRoute, sessionSignal);
|
|
@@ -202,10 +249,28 @@ export async function showSyncManager(
|
|
|
202
249
|
await runRoute("doctor");
|
|
203
250
|
return { kind: "stay" };
|
|
204
251
|
},
|
|
205
|
-
unlock: async () => {
|
|
206
|
-
await
|
|
207
|
-
|
|
252
|
+
unlock: async ({ state, signal: actionSignal }) => {
|
|
253
|
+
const result = await recoverSyncAccess(
|
|
254
|
+
ctx,
|
|
255
|
+
state.manager,
|
|
256
|
+
runRoute,
|
|
257
|
+
sessionSignal,
|
|
258
|
+
actionSignal,
|
|
259
|
+
);
|
|
260
|
+
if (result === "close") return { kind: "close" };
|
|
261
|
+
return result === "restored" ? { kind: "to", screen: "main" } : { kind: "stay" };
|
|
208
262
|
},
|
|
263
|
+
recover: async ({ state, signal: actionSignal }) => {
|
|
264
|
+
const result = await recoverSyncAccess(
|
|
265
|
+
ctx,
|
|
266
|
+
state.manager,
|
|
267
|
+
runRoute,
|
|
268
|
+
sessionSignal,
|
|
269
|
+
actionSignal,
|
|
270
|
+
);
|
|
271
|
+
return { kind: result === "close" ? "close" : "stay" };
|
|
272
|
+
},
|
|
273
|
+
refresh: async () => ({ kind: "stay" }),
|
|
209
274
|
help: async () => {
|
|
210
275
|
await runRoute("help");
|
|
211
276
|
return { kind: "close" };
|
|
@@ -219,52 +284,54 @@ export async function showSyncManager(
|
|
|
219
284
|
});
|
|
220
285
|
await runMenu(ctx, menu, {
|
|
221
286
|
getState: async () => {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
canRecover:
|
|
229
|
-
lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock)),
|
|
230
|
-
};
|
|
287
|
+
const pendingAttention = options.getAttention?.();
|
|
288
|
+
const manager = await describeManagerState(sessionSignal, pendingAttention);
|
|
289
|
+
if (pendingAttention && options.getAttention?.() === pendingAttention && !manager.attention) {
|
|
290
|
+
options.onSelectionResolved?.(pendingAttention);
|
|
291
|
+
}
|
|
292
|
+
return { manager };
|
|
231
293
|
},
|
|
232
294
|
signal: sessionSignal,
|
|
233
295
|
isCurrent: () => !sessionSignal?.aborted,
|
|
234
296
|
});
|
|
235
297
|
}
|
|
236
298
|
|
|
237
|
-
async function resolveManagerDecision(
|
|
238
|
-
ctx: ExtensionCommandContext,
|
|
239
|
-
result: CancellableOperationResult,
|
|
240
|
-
runRoute: RunRoute,
|
|
241
|
-
signal?: AbortSignal,
|
|
242
|
-
) {
|
|
243
|
-
if (result.kind !== "decision-required") return undefined;
|
|
244
|
-
const resolution = await showSyncResolution(ctx, result.decision, runRoute, signal);
|
|
245
|
-
return resolution.kind === "resolved" ||
|
|
246
|
-
resolution.kind === "closed" ||
|
|
247
|
-
resolution.kind === "stale"
|
|
248
|
-
? "close"
|
|
249
|
-
: "stay";
|
|
250
|
-
}
|
|
251
|
-
|
|
252
299
|
function syncMainMenuItem(
|
|
253
300
|
label: string,
|
|
254
301
|
): ActionMenuItem<
|
|
255
302
|
"main" | "more" | "recovery",
|
|
256
|
-
|
|
303
|
+
| "sync"
|
|
304
|
+
| "switch"
|
|
305
|
+
| "diff"
|
|
306
|
+
| "settings"
|
|
307
|
+
| "setups"
|
|
308
|
+
| "connections"
|
|
309
|
+
| "recover"
|
|
310
|
+
| "refresh"
|
|
311
|
+
| "help"
|
|
312
|
+
| "init"
|
|
257
313
|
> {
|
|
258
314
|
if (label === "More…") return { id: "more", label, to: "more" };
|
|
259
315
|
if (label === "History & recovery…") return { id: "recovery", label, to: "recovery" };
|
|
260
316
|
const actions = new Map<
|
|
261
317
|
string,
|
|
262
|
-
|
|
318
|
+
| "sync"
|
|
319
|
+
| "switch"
|
|
320
|
+
| "diff"
|
|
321
|
+
| "settings"
|
|
322
|
+
| "setups"
|
|
323
|
+
| "connections"
|
|
324
|
+
| "recover"
|
|
325
|
+
| "refresh"
|
|
326
|
+
| "help"
|
|
327
|
+
| "init"
|
|
263
328
|
>([
|
|
264
329
|
["Sync now (recommended)", "sync"],
|
|
265
330
|
["Switch sync setup", "switch"],
|
|
266
331
|
["Status & changes", "diff"],
|
|
267
332
|
["Settings", "settings"],
|
|
333
|
+
["Restore sync access… (recommended)", "recover"],
|
|
334
|
+
["Refresh operation status", "refresh"],
|
|
268
335
|
["Sync setups…", "setups"],
|
|
269
336
|
["Storage connections…", "connections"],
|
|
270
337
|
["Help", "help"],
|
|
@@ -553,23 +620,11 @@ async function showSetupSwitcher(
|
|
|
553
620
|
signal,
|
|
554
621
|
},
|
|
555
622
|
);
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
if (pullResult.kind === "decision-required") {
|
|
561
|
-
const resolution = await showSyncResolution(ctx, pullResult.decision, runRoute, signal);
|
|
562
|
-
if (
|
|
563
|
-
resolution.kind === "resolved" ||
|
|
564
|
-
resolution.kind === "closed" ||
|
|
565
|
-
resolution.kind === "stale"
|
|
566
|
-
) {
|
|
567
|
-
pullClosed = true;
|
|
568
|
-
}
|
|
569
|
-
return resolution.kind === "resolved" && resolution.direction === "pull"
|
|
570
|
-
? "applied"
|
|
571
|
-
: undefined;
|
|
623
|
+
const disposition = await dispatchManagerResult(ctx, pullResult, "pull", runRoute, signal);
|
|
624
|
+
if (pullResult.kind === "closed" || pullResult.kind.endsWith("required")) {
|
|
625
|
+
pullClosed = disposition.kind === "close";
|
|
572
626
|
}
|
|
627
|
+
if (disposition.appliedRoute === "pull") return "applied";
|
|
573
628
|
if (pullResult.kind === "completed") return pullResult.outcome;
|
|
574
629
|
return pullResult.kind === "cancelled" ? "cancelled" : undefined;
|
|
575
630
|
},
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { inspectLock, isLockGuardHeld, isStaleLock, type LockInspection } from "./lock.js";
|
|
2
|
+
import { errorMessage } from "./manager-helpers.js";
|
|
3
|
+
import type { LockFile } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type OperationAvailability =
|
|
6
|
+
| { kind: "free" }
|
|
7
|
+
| { kind: "live"; lock: LockFile }
|
|
8
|
+
| { kind: "busy"; lock?: LockFile; metadata: LockInspection["status"] }
|
|
9
|
+
| { kind: "recoverable-stale"; lock: LockFile }
|
|
10
|
+
| { kind: "recoverable-unreadable" }
|
|
11
|
+
| { kind: "inspection-error"; message: string };
|
|
12
|
+
|
|
13
|
+
interface OperationInspectionDependencies {
|
|
14
|
+
inspectMetadata(): Promise<LockInspection>;
|
|
15
|
+
inspectGuard(): Promise<boolean>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_INSPECTION_DEPENDENCIES: OperationInspectionDependencies = {
|
|
19
|
+
inspectMetadata: inspectLock,
|
|
20
|
+
inspectGuard: isLockGuardHeld,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function inspectOperationAvailability(
|
|
24
|
+
dependencies: OperationInspectionDependencies = DEFAULT_INSPECTION_DEPENDENCIES,
|
|
25
|
+
): Promise<OperationAvailability> {
|
|
26
|
+
try {
|
|
27
|
+
const metadata = await dependencies.inspectMetadata();
|
|
28
|
+
const guardHeld = await dependencies.inspectGuard();
|
|
29
|
+
return classifyOperationAvailability(metadata, guardHeld);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { kind: "inspection-error", message: errorMessage(error) };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function classifyOperationAvailability(
|
|
36
|
+
metadata: LockInspection,
|
|
37
|
+
guardHeld: boolean,
|
|
38
|
+
): OperationAvailability {
|
|
39
|
+
if (metadata.status === "valid" && !isStaleLock(metadata.lock)) {
|
|
40
|
+
return { kind: "live", lock: metadata.lock };
|
|
41
|
+
}
|
|
42
|
+
if (guardHeld) {
|
|
43
|
+
return {
|
|
44
|
+
kind: "busy",
|
|
45
|
+
metadata: metadata.status,
|
|
46
|
+
...(metadata.status === "valid" ? { lock: metadata.lock } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (metadata.status === "valid") {
|
|
50
|
+
return { kind: "recoverable-stale", lock: metadata.lock };
|
|
51
|
+
}
|
|
52
|
+
if (metadata.status === "unreadable") return { kind: "recoverable-unreadable" };
|
|
53
|
+
return { kind: "free" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function operationBlocksChanges(availability: OperationAvailability) {
|
|
57
|
+
return availability.kind !== "free";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function operationCanRecover(availability: OperationAvailability) {
|
|
61
|
+
return (
|
|
62
|
+
availability.kind === "recoverable-stale" || availability.kind === "recoverable-unreadable"
|
|
63
|
+
);
|
|
64
|
+
}
|