@narumitw/pi-sync 0.53.1 → 0.54.1
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 +14 -4
- package/dist/chunks/{chunk-TRCSEIAQ.ts → chunk-5ZWQ33XL.ts} +38 -18
- package/dist/chunks/{chunk-TRCSEIAQ.ts.map → chunk-5ZWQ33XL.ts.map} +2 -2
- package/dist/chunks/{chunk-QDPED3XJ.ts → chunk-HUTYAPFP.ts} +407 -142
- package/dist/chunks/chunk-HUTYAPFP.ts.map +7 -0
- package/dist/chunks/{chunk-FJGNDYGC.ts → chunk-VHXBCEVF.ts} +2 -2
- package/dist/chunks/{conflict-review-JANNPKKS.ts → conflict-review-JRFJIBEN.ts} +2 -2
- package/dist/chunks/{manager-result-dispatcher-AAEAYX57.ts → manager-result-dispatcher-6MQKFCWB.ts} +3 -3
- package/dist/chunks/{manager-ui-MHAHAKG3.ts → manager-ui-IURS4WXY.ts} +674 -609
- package/dist/chunks/manager-ui-IURS4WXY.ts.map +7 -0
- package/dist/chunks/{remote-selection-ui-Q2HUIBTL.ts → remote-selection-ui-6LJJ4IMQ.ts} +2 -2
- package/dist/chunks/{sync-operations-Y27F5T7W.ts → sync-operations-QQ2GXKVC.ts} +15 -9
- package/dist/chunks/{sync-operations-Y27F5T7W.ts.map → sync-operations-QQ2GXKVC.ts.map} +2 -2
- package/dist/index.ts +32 -7
- package/dist/index.ts.map +3 -3
- package/docs/settings.md +11 -3
- package/package.json +4 -4
- package/src/commands/command-execution.ts +3 -0
- package/src/commands/command-handler.ts +8 -1
- package/src/sync/merged-sync.ts +28 -24
- package/src/sync/sync-mutations.ts +9 -7
- package/src/sync/sync-queries.ts +1 -0
- package/src/ui/cancellable-operation.ts +47 -17
- package/src/ui/manager-attention.ts +3 -5
- package/src/ui/manager-state.ts +32 -15
- package/src/ui/manager-ui.ts +59 -87
- package/src/ui/merge-review.ts +79 -19
- package/src/ui/merged-sync-summary.ts +128 -0
- package/src/ui/operation-dialogs.ts +29 -0
- package/src/ui/settings-ui.ts +140 -46
- package/src/ui/setup/setup-actions.ts +2 -2
- package/src/ui/sync-confirmation.ts +153 -0
- package/dist/chunks/chunk-QDPED3XJ.ts.map +0 -7
- package/dist/chunks/manager-ui-MHAHAKG3.ts.map +0 -7
- /package/dist/chunks/{chunk-FJGNDYGC.ts.map → chunk-VHXBCEVF.ts.map} +0 -0
- /package/dist/chunks/{conflict-review-JANNPKKS.ts.map → conflict-review-JRFJIBEN.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-AAEAYX57.ts.map → manager-result-dispatcher-6MQKFCWB.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-Q2HUIBTL.ts.map → remote-selection-ui-6LJJ4IMQ.ts.map} +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { PublicationCapability } from "../backends/sync-backend.js";
|
|
3
|
+
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
4
|
+
import type { FileMergeDecision } from "../sync/file-merge-planner.js";
|
|
5
|
+
import { fileHashMap } from "../sync/sync-state.js";
|
|
6
|
+
import { safeTerminalText, snapshotPathLabel } from "./terminal-text.js";
|
|
7
|
+
|
|
8
|
+
interface FileChange {
|
|
9
|
+
path: string;
|
|
10
|
+
action: "Add" | "Update" | "Delete";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MergedSyncSummaryInput {
|
|
14
|
+
setupName: string;
|
|
15
|
+
destination: string;
|
|
16
|
+
localBefore: Snapshot;
|
|
17
|
+
localAfter: Snapshot;
|
|
18
|
+
remoteBefore: Snapshot;
|
|
19
|
+
remoteAfter: Snapshot;
|
|
20
|
+
publish: boolean;
|
|
21
|
+
sessions: boolean;
|
|
22
|
+
groups: readonly { paths: readonly string[] }[];
|
|
23
|
+
decisions: readonly FileMergeDecision[];
|
|
24
|
+
capability: PublicationCapability;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Presentation only: counts describe snapshot differences, not planner decisions. */
|
|
28
|
+
export function mergedSyncSummary(input: MergedSyncSummaryInput) {
|
|
29
|
+
const local = changes(input.localBefore, input.localAfter);
|
|
30
|
+
const remote = changes(input.remoteBefore, input.remoteAfter);
|
|
31
|
+
const all = [...local, ...remote];
|
|
32
|
+
const updatesOnly = all.length > 0 && all.every((change) => change.action === "Update");
|
|
33
|
+
const verb = updatesOnly ? "Update" : "Apply changes to";
|
|
34
|
+
const title =
|
|
35
|
+
local.length && remote.length
|
|
36
|
+
? "Apply local and remote changes?"
|
|
37
|
+
: all.length
|
|
38
|
+
? `${verb} ${all.length} ${local.length ? "local" : "remote"} ${all.length === 1 ? "file" : "files"}?`
|
|
39
|
+
: input.publish
|
|
40
|
+
? "Publish a new remote snapshot?"
|
|
41
|
+
: "Pi Sync is already up to date.";
|
|
42
|
+
const confirmationLabel =
|
|
43
|
+
local.length && remote.length
|
|
44
|
+
? "apply local and remote changes"
|
|
45
|
+
: all.length
|
|
46
|
+
? `${updatesOnly ? "update" : "apply changes to"} ${local.length ? "local" : "remote"} ${all.length === 1 ? "file" : "files"}`
|
|
47
|
+
: "publish remote snapshot";
|
|
48
|
+
const sources = new Map(
|
|
49
|
+
input.decisions.filter((item) => item.kind === "accepted").map((item) => [item.path, item.source]),
|
|
50
|
+
);
|
|
51
|
+
const label = (change: FileChange, side: "local" | "remote") => {
|
|
52
|
+
const source = sources.get(change.path);
|
|
53
|
+
const suffix =
|
|
54
|
+
source === "merged"
|
|
55
|
+
? " (merged content)"
|
|
56
|
+
: side === "local" && source === "remote"
|
|
57
|
+
? change.action === "Delete"
|
|
58
|
+
? " (deleted remotely)"
|
|
59
|
+
: " from remote"
|
|
60
|
+
: "";
|
|
61
|
+
return `${change.action} ${snapshotPathLabel(change.path)}${suffix}`;
|
|
62
|
+
};
|
|
63
|
+
const section = (items: FileChange[], side: "local" | "remote", compact: boolean) => {
|
|
64
|
+
const heading = side === "local" ? "Local changes" : "Remote file changes";
|
|
65
|
+
if (!items.length) return [`${heading}: none`];
|
|
66
|
+
const counts = ["Add", "Update", "Delete"].flatMap((action) => {
|
|
67
|
+
const count = items.filter((item) => item.action === action).length;
|
|
68
|
+
return count ? [`${count} ${action.toLowerCase()}`] : [];
|
|
69
|
+
});
|
|
70
|
+
return [
|
|
71
|
+
`${heading}: ${counts.join(", ")}`,
|
|
72
|
+
...(compact ? items.slice(0, 3) : items).map(
|
|
73
|
+
(item) => ` ${compact ? truncateToWidth(label(item, side), 80) : label(item, side)}`,
|
|
74
|
+
),
|
|
75
|
+
...(compact && items.length > 3 ? [` ${items.length - 3} more files — View details`] : []),
|
|
76
|
+
];
|
|
77
|
+
};
|
|
78
|
+
const context = [`Setup: ${safeTerminalText(input.setupName)}`, `Remote: ${safeTerminalText(input.destination)}`];
|
|
79
|
+
const safety = [
|
|
80
|
+
`Remote snapshot publication: ${input.publish ? "yes" : "no"}`,
|
|
81
|
+
`Sessions: ${input.sessions ? "included — may contain private conversations" : "not included"}`,
|
|
82
|
+
input.groups.length
|
|
83
|
+
? `Unresolved groups: ${input.groups.length} — these paths stay unchanged on each side`
|
|
84
|
+
: "Conflicts: none",
|
|
85
|
+
...(input.publish && input.capability === "read-check-write-verify"
|
|
86
|
+
? ["Warning: simultaneous remote writes can still race; visible changes are rejected."]
|
|
87
|
+
: []),
|
|
88
|
+
...(input.publish && input.capability === "conditional-required"
|
|
89
|
+
? ["Remote publication requires verified conditional writes; verification failure stops the transfer."]
|
|
90
|
+
: []),
|
|
91
|
+
"The current session is protected. A backup and recovery journal will be saved.",
|
|
92
|
+
"Resources will not reload automatically.",
|
|
93
|
+
];
|
|
94
|
+
return {
|
|
95
|
+
title,
|
|
96
|
+
confirmationLabel,
|
|
97
|
+
lines: [
|
|
98
|
+
...context.map((line) => truncateToWidth(line, 100)),
|
|
99
|
+
...section(local, "local", true),
|
|
100
|
+
...section(remote, "remote", true),
|
|
101
|
+
...safety,
|
|
102
|
+
],
|
|
103
|
+
content: [
|
|
104
|
+
...context,
|
|
105
|
+
"",
|
|
106
|
+
...section(local, "local", false),
|
|
107
|
+
"",
|
|
108
|
+
...section(remote, "remote", false),
|
|
109
|
+
"",
|
|
110
|
+
...safety,
|
|
111
|
+
...input.groups.flatMap((group) =>
|
|
112
|
+
group.paths.map((filePath) => `Unchanged (unresolved): ${snapshotPathLabel(filePath)}`),
|
|
113
|
+
),
|
|
114
|
+
].join("\n"),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
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,29 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Track History's Pi-owned selector/confirmation lifetime without inspecting renderer internals. */
|
|
4
|
+
export function trackOperationDialogs<Context extends ExtensionContext>(
|
|
5
|
+
ctx: Context,
|
|
6
|
+
onDialog?: (active: boolean) => void,
|
|
7
|
+
): Context {
|
|
8
|
+
if (!onDialog) return ctx;
|
|
9
|
+
const runDialog = async <Value>(task: () => Promise<Value>): Promise<Value> => {
|
|
10
|
+
onDialog(true);
|
|
11
|
+
try {
|
|
12
|
+
return await task();
|
|
13
|
+
} finally {
|
|
14
|
+
onDialog(false);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const ui: ExtensionContext["ui"] = {
|
|
18
|
+
...ctx.ui,
|
|
19
|
+
select: (...args) => runDialog(() => ctx.ui.select(...args)),
|
|
20
|
+
confirm: (...args) => runDialog(() => ctx.ui.confirm(...args)),
|
|
21
|
+
};
|
|
22
|
+
// Forward live context properties: copying the context would hide session
|
|
23
|
+
// replacement from mutation-owner revalidation after a dialog settles.
|
|
24
|
+
return new Proxy(ctx, {
|
|
25
|
+
get(target, key, receiver) {
|
|
26
|
+
return key === "ui" ? ui : Reflect.get(target, key, receiver);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
package/src/ui/settings-ui.ts
CHANGED
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
import { captureMutationOwner } from "../sync/sync-local.js";
|
|
15
15
|
import type { RunRoute } from "./cancellable-operation.js";
|
|
16
16
|
import { dispatchManagerResult } from "./manager-result-dispatcher.js";
|
|
17
|
+
import { showEditSyncSetupStorage, showSyncSetupManager } from "./setup/setup-actions.js";
|
|
17
18
|
import { AUTOMATIC_SYNC_DESCRIPTION } from "./setup/setup-prompts.js";
|
|
19
|
+
import { showStorageConnections } from "./storage-connections-ui.js";
|
|
18
20
|
import { configureSyncStatus } from "./sync-status.js";
|
|
19
21
|
import { safeTerminalText } from "./terminal-text.js";
|
|
20
22
|
|
|
@@ -24,13 +26,46 @@ export async function showSyncSettings(
|
|
|
24
26
|
ctx: ExtensionCommandContext,
|
|
25
27
|
runRoute: SyncSettingsRoute,
|
|
26
28
|
signal?: AbortSignal,
|
|
27
|
-
) {
|
|
29
|
+
): Promise<"back" | "exit"> {
|
|
30
|
+
let disposition: "back" | "exit" = "back";
|
|
31
|
+
const exitSettings = () => {
|
|
32
|
+
disposition = "exit";
|
|
33
|
+
return { kind: "close" as const };
|
|
34
|
+
};
|
|
28
35
|
if (ctx.mode !== "tui") {
|
|
29
36
|
ctx.ui.notify(`Edit pi-sync settings manually: ${safeTerminalText(localConfigPath())}`, "info");
|
|
30
|
-
|
|
37
|
+
if (ctx.mode === "rpc" && ctx.hasUI) {
|
|
38
|
+
// Preserve RPC catalog management; preference/credential editing stays TUI-only.
|
|
39
|
+
const management = defineMenu<undefined, "management", "setups" | "connections", ExtensionCommandContext>({
|
|
40
|
+
start: "management",
|
|
41
|
+
screens: {
|
|
42
|
+
management: () => ({
|
|
43
|
+
kind: "actions",
|
|
44
|
+
title: "Sync settings management",
|
|
45
|
+
items: [
|
|
46
|
+
{ id: "setups", label: "Manage sync setups", action: "setups" },
|
|
47
|
+
{ id: "connections", label: "Manage storage connections", action: "connections" },
|
|
48
|
+
],
|
|
49
|
+
hint: "back",
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
actions: {
|
|
53
|
+
setups: async ({ signal: actionSignal }) => {
|
|
54
|
+
const result = await showSyncSetupManager(ctx, runRoute, actionSignal);
|
|
55
|
+
return result === "exit" ? exitSettings() : { kind: "stay" };
|
|
56
|
+
},
|
|
57
|
+
connections: async ({ signal: actionSignal }) => {
|
|
58
|
+
await showStorageConnections(ctx, actionSignal);
|
|
59
|
+
return { kind: "stay" };
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
await runMenu(ctx, management, { getState: () => undefined, signal, isCurrent: () => !signal?.aborted });
|
|
64
|
+
}
|
|
65
|
+
return disposition;
|
|
31
66
|
}
|
|
32
67
|
const initial = await loadConfig();
|
|
33
|
-
if (signal?.aborted) return;
|
|
68
|
+
if (signal?.aborted) return "back";
|
|
34
69
|
const setupName = initial.setupName;
|
|
35
70
|
type Action =
|
|
36
71
|
| "automatic"
|
|
@@ -42,7 +77,10 @@ export async function showSyncSettings(
|
|
|
42
77
|
| "show-status"
|
|
43
78
|
| "on-switch"
|
|
44
79
|
| "include"
|
|
45
|
-
| "remote-include"
|
|
80
|
+
| "remote-include"
|
|
81
|
+
| "storage-location"
|
|
82
|
+
| "setups"
|
|
83
|
+
| "connections";
|
|
46
84
|
const menu = defineMenu<Awaited<ReturnType<typeof loadConfig>>, "settings", Action, ExtensionCommandContext>({
|
|
47
85
|
start: "settings",
|
|
48
86
|
screens: {
|
|
@@ -52,39 +90,9 @@ export async function showSyncSettings(
|
|
|
52
90
|
lines: [
|
|
53
91
|
`Sync setup: ${safeTerminalText(state.setupName)} · Storage connection: ${safeTerminalText(state.connectionName)}`,
|
|
54
92
|
],
|
|
93
|
+
// Flat, searchable task ordering: Content, Destination, Automation, Merge, Other.
|
|
94
|
+
// Kit has no heading rows; avoid focusable fake headings or a new component.
|
|
55
95
|
items: [
|
|
56
|
-
{
|
|
57
|
-
id: "automatic",
|
|
58
|
-
label: "Automatic sync",
|
|
59
|
-
description: AUTOMATIC_SYNC_DESCRIPTION,
|
|
60
|
-
currentValue: state.automatic ? "On" : "Off",
|
|
61
|
-
values: ["On", "Off"],
|
|
62
|
-
action: "automatic",
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
id: "skipSecretScan",
|
|
66
|
-
label: "Skip secret scan (all setups)",
|
|
67
|
-
description: "All setups: allow pushes without checking managed local files for possible secrets.",
|
|
68
|
-
currentValue: state.skipSecretScan ? "On" : "Off",
|
|
69
|
-
values: ["On", "Off"],
|
|
70
|
-
action: "skip-secret-scan",
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
id: "showStatus",
|
|
74
|
-
label: "Show status (all setups)",
|
|
75
|
-
description: "All setups: show sync progress and attention in Pi status.",
|
|
76
|
-
currentValue: state.showStatus ? "On" : "Off",
|
|
77
|
-
values: ["On", "Off"],
|
|
78
|
-
action: "show-status",
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
id: "onSwitch",
|
|
82
|
-
label: "After switching setup (all setups)",
|
|
83
|
-
description: "All setups: ask before a pull review, open it automatically, or switch without pulling.",
|
|
84
|
-
currentValue: setupSwitchActionLabel(state.onSwitch),
|
|
85
|
-
values: SETUP_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
|
|
86
|
-
action: "on-switch",
|
|
87
|
-
},
|
|
88
96
|
{
|
|
89
97
|
id: "include",
|
|
90
98
|
label: "Included content",
|
|
@@ -99,6 +107,49 @@ export async function showSyncSettings(
|
|
|
99
107
|
currentValue: "Review",
|
|
100
108
|
action: "remote-include",
|
|
101
109
|
},
|
|
110
|
+
{
|
|
111
|
+
id: "localFields",
|
|
112
|
+
label: "Machine-local settings fields",
|
|
113
|
+
description:
|
|
114
|
+
"Root field names omitted from future portable snapshots. Policy changes require directional migration; old history remains.",
|
|
115
|
+
currentValue: `${state.localFields?.length ?? 0} fields · Edit`,
|
|
116
|
+
action: "local-fields",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "storageLocation",
|
|
120
|
+
label: "Storage location",
|
|
121
|
+
description: "Destination: edit this setup's bucket, branch, or path; remote history is not moved.",
|
|
122
|
+
currentValue:
|
|
123
|
+
state.backend.type === "s3"
|
|
124
|
+
? `${safeTerminalText(state.backend.destination.bucket)} · ${safeTerminalText(state.backend.destination.prefix)}`
|
|
125
|
+
: state.backend.type === "git"
|
|
126
|
+
? `${safeTerminalText(state.backend.destination.branch)} · ${safeTerminalText(state.backend.destination.directory)}`
|
|
127
|
+
: safeTerminalText(state.backend.destination.path),
|
|
128
|
+
action: "storage-location",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "setups",
|
|
132
|
+
label: "Manage sync setups",
|
|
133
|
+
description: "Destination: add, switch, edit, or remove sync setups.",
|
|
134
|
+
currentValue: "Open",
|
|
135
|
+
action: "setups",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "connections",
|
|
139
|
+
label: "Manage storage connections",
|
|
140
|
+
description:
|
|
141
|
+
"Destination: manage shared server addresses and credentials; edits may affect multiple setups.",
|
|
142
|
+
currentValue: "Open",
|
|
143
|
+
action: "connections",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "automatic",
|
|
147
|
+
label: "Automatic sync",
|
|
148
|
+
description: AUTOMATIC_SYNC_DESCRIPTION,
|
|
149
|
+
currentValue: state.automatic ? "On" : "Off",
|
|
150
|
+
values: ["On", "Off"],
|
|
151
|
+
action: "automatic",
|
|
152
|
+
},
|
|
102
153
|
{
|
|
103
154
|
id: "automaticTransfer",
|
|
104
155
|
label: "Automatic transfer at startup",
|
|
@@ -108,6 +159,14 @@ export async function showSyncSettings(
|
|
|
108
159
|
values: ["On", "Off"],
|
|
109
160
|
action: "automatic-transfer",
|
|
110
161
|
},
|
|
162
|
+
{
|
|
163
|
+
id: "onSwitch",
|
|
164
|
+
label: "After switching setup (all setups)",
|
|
165
|
+
description: "All setups: ask before a pull review, open it automatically, or switch without pulling.",
|
|
166
|
+
currentValue: setupSwitchActionLabel(state.onSwitch),
|
|
167
|
+
values: SETUP_SWITCH_ACTION_OPTIONS.map(({ label }) => label),
|
|
168
|
+
action: "on-switch",
|
|
169
|
+
},
|
|
111
170
|
{
|
|
112
171
|
id: "mergeSettings",
|
|
113
172
|
label: "Settings field merge (experimental)",
|
|
@@ -117,14 +176,6 @@ export async function showSyncSettings(
|
|
|
117
176
|
values: ["On", "Off"],
|
|
118
177
|
action: "merge-settings",
|
|
119
178
|
},
|
|
120
|
-
{
|
|
121
|
-
id: "localFields",
|
|
122
|
-
label: "Machine-local settings fields",
|
|
123
|
-
description:
|
|
124
|
-
"Root field names omitted from future portable snapshots. Policy changes require directional migration; old history remains.",
|
|
125
|
-
currentValue: `${state.localFields?.length ?? 0} fields · Edit`,
|
|
126
|
-
action: "local-fields",
|
|
127
|
-
},
|
|
128
179
|
{
|
|
129
180
|
id: "contentPolicy",
|
|
130
181
|
label: "Content / partial sync (experimental)",
|
|
@@ -140,10 +191,41 @@ export async function showSyncSettings(
|
|
|
140
191
|
values: ["Off", "Content only", "Content & partial", "Partial only"],
|
|
141
192
|
action: "content-policy",
|
|
142
193
|
},
|
|
194
|
+
{
|
|
195
|
+
id: "skipSecretScan",
|
|
196
|
+
label: "Skip secret scan (all setups)",
|
|
197
|
+
description: "All setups: allow pushes without checking managed local files for possible secrets.",
|
|
198
|
+
currentValue: state.skipSecretScan ? "On" : "Off",
|
|
199
|
+
values: ["On", "Off"],
|
|
200
|
+
action: "skip-secret-scan",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: "showStatus",
|
|
204
|
+
label: "Show status (all setups)",
|
|
205
|
+
description: "All setups: show sync progress and attention in Pi status.",
|
|
206
|
+
currentValue: state.showStatus ? "On" : "Off",
|
|
207
|
+
values: ["On", "Off"],
|
|
208
|
+
action: "show-status",
|
|
209
|
+
},
|
|
143
210
|
],
|
|
144
211
|
}),
|
|
145
212
|
},
|
|
146
213
|
actions: {
|
|
214
|
+
"storage-location": async ({ signal: actionSignal }) => {
|
|
215
|
+
const editorSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
216
|
+
await showEditSyncSetupStorage(ctx, setupName, editorSignal);
|
|
217
|
+
return resumeSettings(editorSignal);
|
|
218
|
+
},
|
|
219
|
+
setups: async ({ signal: actionSignal }) => {
|
|
220
|
+
const editorSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
221
|
+
const result = await showSyncSetupManager(ctx, runRoute, editorSignal);
|
|
222
|
+
return result === "exit" ? exitSettings() : resumeSettings(editorSignal);
|
|
223
|
+
},
|
|
224
|
+
connections: async ({ signal: actionSignal }) => {
|
|
225
|
+
const editorSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
226
|
+
await showStorageConnections(ctx, editorSignal);
|
|
227
|
+
return resumeSettings(editorSignal);
|
|
228
|
+
},
|
|
147
229
|
"content-policy": async ({ value, signal: actionSignal }) => {
|
|
148
230
|
const mutationSignal = signal ? AbortSignal.any([signal, actionSignal]) : actionSignal;
|
|
149
231
|
const validate = captureMutationOwner(ctx, mutationSignal);
|
|
@@ -356,17 +438,29 @@ export async function showSyncSettings(
|
|
|
356
438
|
if (reviewSignal.aborted) return { kind: "rejected" };
|
|
357
439
|
if (review.kind === "route-result") {
|
|
358
440
|
const disposition = await dispatchManagerResult(ctx, review.result, review.route, runRoute, reviewSignal);
|
|
359
|
-
return disposition.kind === "close" ?
|
|
441
|
+
return disposition.kind === "close" ? exitSettings() : { kind: "stay" };
|
|
360
442
|
}
|
|
361
|
-
return review.kind === "closed" || review.kind === "stale" ?
|
|
443
|
+
return review.kind === "closed" || review.kind === "stale" ? exitSettings() : { kind: "stay" };
|
|
362
444
|
},
|
|
363
445
|
},
|
|
364
446
|
});
|
|
447
|
+
async function resumeSettings(editorSignal: AbortSignal) {
|
|
448
|
+
if (editorSignal.aborted) return { kind: "close" as const };
|
|
449
|
+
try {
|
|
450
|
+
const active = await loadConfig();
|
|
451
|
+
if (editorSignal.aborted || active.setupName !== setupName) return { kind: "close" as const };
|
|
452
|
+
// Stay reloads authoritative rows without pushing a duplicate screen or clearing search.
|
|
453
|
+
return { kind: "stay" as const };
|
|
454
|
+
} catch {
|
|
455
|
+
return { kind: "close" as const };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
365
458
|
await runMenu(ctx, menu, {
|
|
366
459
|
getState: () => loadConfig(setupName),
|
|
367
460
|
signal,
|
|
368
461
|
isCurrent: () => !signal?.aborted,
|
|
369
462
|
});
|
|
463
|
+
return disposition;
|
|
370
464
|
}
|
|
371
465
|
|
|
372
466
|
function notifySaveFailure(ctx: ExtensionCommandContext, error: unknown) {
|
|
@@ -22,7 +22,7 @@ export async function showSyncSetupManager(ctx: ExtensionCommandContext, runRout
|
|
|
22
22
|
await showAddTarget(ctx, setupSignal);
|
|
23
23
|
},
|
|
24
24
|
edit: async (name, setupSignal) => {
|
|
25
|
-
await
|
|
25
|
+
await showEditSyncSetupStorage(ctx, name, setupSignal);
|
|
26
26
|
},
|
|
27
27
|
makeCurrent: async (name, setupSignal) => {
|
|
28
28
|
const result = await showSetupSwitcher(ctx, runRoute, name, setupSignal);
|
|
@@ -84,7 +84,7 @@ async function showAddTarget(ctx: ExtensionCommandContext, signal?: AbortSignal)
|
|
|
84
84
|
await showAddS3Target(ctx, raw, profile, name, signal);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
async function
|
|
87
|
+
export async function showEditSyncSetupStorage(ctx: ExtensionCommandContext, name: string, signal?: AbortSignal) {
|
|
88
88
|
const partial = await loadPartialConfig(name);
|
|
89
89
|
if (signal?.aborted) return;
|
|
90
90
|
if (!partial.setupName) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, SelectList, type TUI, type TuiMouseEvent, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { HorizontalRule, hardWrapTerminalDocument, sanitizeTerminalText } from "@narumitw/pi-tui-kit";
|
|
4
|
+
|
|
5
|
+
export type SyncConfirmationChoice = "cancel" | "approve" | "details";
|
|
6
|
+
|
|
7
|
+
/** Kit choice context is clipped on short terminals; this summary must remain reviewable. */
|
|
8
|
+
export function createSyncConfirmation(options: {
|
|
9
|
+
title: string;
|
|
10
|
+
lines: readonly string[];
|
|
11
|
+
confirmationLabel: string;
|
|
12
|
+
tui: TUI;
|
|
13
|
+
theme: Theme;
|
|
14
|
+
keybindings: KeybindingsManager;
|
|
15
|
+
complete(choice: SyncConfirmationChoice): void;
|
|
16
|
+
}) {
|
|
17
|
+
const { tui, theme, keybindings } = options;
|
|
18
|
+
const choices: SyncConfirmationChoice[] = ["cancel", "approve", "details"];
|
|
19
|
+
const list = new SelectList(
|
|
20
|
+
[
|
|
21
|
+
{ value: "cancel", label: "No, cancel" },
|
|
22
|
+
{ value: "approve", label: `Yes, ${sanitizeTerminalText(options.confirmationLabel)}` },
|
|
23
|
+
{ value: "details", label: "View details" },
|
|
24
|
+
],
|
|
25
|
+
3,
|
|
26
|
+
{
|
|
27
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
28
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
29
|
+
description: (text) => theme.fg("muted", text),
|
|
30
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
31
|
+
noMatch: (text) => theme.fg("muted", text),
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
|
|
35
|
+
let selected = 0;
|
|
36
|
+
let offset = 0;
|
|
37
|
+
let maximumOffset = 0;
|
|
38
|
+
let lastWidth = 0;
|
|
39
|
+
let lastRows = 0;
|
|
40
|
+
let seen = new Set<number>();
|
|
41
|
+
let summary: readonly string[] = [];
|
|
42
|
+
let reviewed = false;
|
|
43
|
+
let disposed = false;
|
|
44
|
+
let finished = false;
|
|
45
|
+
let summaryStart = 0;
|
|
46
|
+
let summaryEnd = 0;
|
|
47
|
+
let actionsStart = 0;
|
|
48
|
+
const finish = (choice: SyncConfirmationChoice) => {
|
|
49
|
+
if (disposed || finished) return;
|
|
50
|
+
finished = true;
|
|
51
|
+
options.complete(choice);
|
|
52
|
+
};
|
|
53
|
+
const select = (index: number) => {
|
|
54
|
+
selected = (index + choices.length) % choices.length;
|
|
55
|
+
list.setSelectedIndex(selected);
|
|
56
|
+
};
|
|
57
|
+
list.onSelectionChange = (item) => {
|
|
58
|
+
selected = Math.max(0, choices.indexOf(item.value as SyncConfirmationChoice));
|
|
59
|
+
};
|
|
60
|
+
list.onSelect = (item) => {
|
|
61
|
+
const choice = choices[selected] ?? "cancel";
|
|
62
|
+
if (item.value === choice && (choice !== "approve" || reviewed)) finish(choice);
|
|
63
|
+
};
|
|
64
|
+
const component = {
|
|
65
|
+
render(width: number, expose = true): string[] {
|
|
66
|
+
if (!Number.isFinite(width) || width <= 0) {
|
|
67
|
+
reviewed = false;
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const cells = Math.floor(width);
|
|
71
|
+
const rows = Math.max(1, Math.floor(tui.terminal.rows) - 3);
|
|
72
|
+
if (cells !== lastWidth || rows !== lastRows) {
|
|
73
|
+
// Reflow can expose previously unseen rows or hide the selected action.
|
|
74
|
+
// Reset proof and selection rather than carry approval across a resize.
|
|
75
|
+
summary = options.lines.flatMap((line) => hardWrapTerminalDocument(line, cells));
|
|
76
|
+
seen = new Set();
|
|
77
|
+
offset = 0;
|
|
78
|
+
select(0);
|
|
79
|
+
lastWidth = cells;
|
|
80
|
+
lastRows = rows;
|
|
81
|
+
}
|
|
82
|
+
const framed = rows >= 9;
|
|
83
|
+
const actions = list.render(cells);
|
|
84
|
+
const viewport = Math.max(0, rows - actions.length - 3 - (framed ? 2 : 0));
|
|
85
|
+
maximumOffset = Math.max(0, summary.length - viewport);
|
|
86
|
+
offset = Math.min(offset, maximumOffset);
|
|
87
|
+
const visible = summary.slice(offset, offset + viewport);
|
|
88
|
+
if (expose) for (let index = offset; index < offset + visible.length; index++) seen.add(index);
|
|
89
|
+
reviewed = viewport > 0 && seen.size === summary.length;
|
|
90
|
+
// Describe actions without advertising configured keys that can be invalid,
|
|
91
|
+
// aliased, or shadowed by an earlier cancellation/navigation binding.
|
|
92
|
+
const hint = reviewed ? "Select an action." : "Navigation keys scroll the summary before selecting Yes.";
|
|
93
|
+
const body = [
|
|
94
|
+
theme.fg("accent", theme.bold(hardWrapTerminalDocument(options.title, cells).join(" "))),
|
|
95
|
+
...visible.map((line) => theme.fg("text", line)),
|
|
96
|
+
theme.fg(
|
|
97
|
+
"muted",
|
|
98
|
+
`Summary ${visible.length ? offset + 1 : 0}-${offset + visible.length}/${summary.length}${reviewed ? "" : " — review before Yes"}`,
|
|
99
|
+
),
|
|
100
|
+
...actions,
|
|
101
|
+
theme.fg("dim", hint),
|
|
102
|
+
];
|
|
103
|
+
summaryStart = Number(framed) + 1;
|
|
104
|
+
summaryEnd = summaryStart + visible.length;
|
|
105
|
+
actionsStart = summaryEnd + 1;
|
|
106
|
+
const frame = framed ? [...rule.render(cells), ...body, ...rule.render(cells)] : body;
|
|
107
|
+
// Very small terminals cannot display the summary and all choices together.
|
|
108
|
+
// Keep cancellation available, but never approve a zero-row summary viewport.
|
|
109
|
+
return frame.slice(0, rows).map((line) => truncateToWidth(line, cells, ""));
|
|
110
|
+
},
|
|
111
|
+
invalidate() {
|
|
112
|
+
list.invalidate();
|
|
113
|
+
rule.invalidate();
|
|
114
|
+
},
|
|
115
|
+
handleInput(data: string) {
|
|
116
|
+
if (disposed || finished) return;
|
|
117
|
+
if (matchesKey(data, Key.ctrl("c")) || keybindings.matches(data, "tui.select.cancel")) {
|
|
118
|
+
finish("cancel");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Refresh height/width proof before handling an input racing with terminal resize.
|
|
122
|
+
component.render(tui.terminal.columns ?? lastWidth, false);
|
|
123
|
+
if (keybindings.matches(data, "tui.select.up")) {
|
|
124
|
+
if (!reviewed || (selected === 0 && offset > 0)) offset = Math.max(0, offset - 1);
|
|
125
|
+
else select(selected - 1);
|
|
126
|
+
} else if (keybindings.matches(data, "tui.select.down")) {
|
|
127
|
+
if (!reviewed || (selected === 0 && offset < maximumOffset)) offset = Math.min(maximumOffset, offset + 1);
|
|
128
|
+
else select(selected + 1);
|
|
129
|
+
} else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
130
|
+
const choice = choices[selected] ?? "cancel";
|
|
131
|
+
if (choice !== "approve" || reviewed) finish(choice);
|
|
132
|
+
}
|
|
133
|
+
tui.requestRender();
|
|
134
|
+
},
|
|
135
|
+
handleMouse(event: TuiMouseEvent) {
|
|
136
|
+
if (disposed || finished || event.width !== lastWidth) return undefined;
|
|
137
|
+
component.render(event.width, false);
|
|
138
|
+
if (event.y >= summaryStart && event.y < summaryEnd && event.type === "wheel" && event.wheelDelta) {
|
|
139
|
+
offset = Math.max(0, Math.min(maximumOffset, offset + (event.wheelDelta < 0 ? -1 : 1)));
|
|
140
|
+
select(0);
|
|
141
|
+
tui.requestRender();
|
|
142
|
+
return { handled: true, render: true };
|
|
143
|
+
}
|
|
144
|
+
if (event.y < actionsStart || event.y >= actionsStart + choices.length || event.y >= lastRows) return undefined;
|
|
145
|
+
return list.handleMouse({ ...event, y: event.y - actionsStart, height: choices.length });
|
|
146
|
+
},
|
|
147
|
+
dispose() {
|
|
148
|
+
disposed = true;
|
|
149
|
+
seen.clear();
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
return component;
|
|
153
|
+
}
|