@narumitw/pi-sync 0.34.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/package.json +2 -1
- package/src/config-file.ts +98 -60
- package/src/file-selection.ts +88 -73
- package/src/manager-state.ts +155 -0
- package/src/manager-ui.ts +230 -289
- package/src/settings-ui.ts +103 -145
- package/src/storage-connections-ui.ts +157 -83
- package/src/sync-setups-ui.ts +186 -99
package/README.md
CHANGED
|
@@ -52,7 +52,11 @@ Automatic sync: On
|
|
|
52
52
|
Remote status: Not checked
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
Primary actions include **Sync now**, **Switch sync setup**, **Status & changes**, **Settings**, and
|
|
55
|
+
Primary actions include **Sync now**, **Switch sync setup**, **Status & changes**, **Settings**, and
|
|
56
|
+
**More…**. The standard manager also owns setup and connection lists/details plus history and
|
|
57
|
+
recovery. Secondary screens provide **Back**; Escape goes back and Ctrl+C closes the flow.
|
|
58
|
+
Destructive, credential-bearing, and externally visible operations retain exact specialized previews
|
|
59
|
+
and confirmations.
|
|
56
60
|
|
|
57
61
|
## ⚙️ Settings
|
|
58
62
|
|
|
@@ -62,7 +66,7 @@ The canonical private user file is:
|
|
|
62
66
|
~/.pi/agent/pi-sync.json
|
|
63
67
|
```
|
|
64
68
|
|
|
65
|
-
Pi's configured agent directory replaces `~/.pi/agent` when applicable. On POSIX, pi-sync creates and replaces
|
|
69
|
+
Pi's configured agent directory replaces `~/.pi/agent` when applicable. Missing settings load as unconfigured without creating an agent directory, file, temporary, or lock. An explicit setup creates the file atomically. On POSIX, pi-sync creates and replaces it with mode `0600`. Pi-sync processes coordinate settings access through `pi-sync.json.mutation-lock`; lock-unaware editors are outside that serialization boundary and should not save the file while a pi-sync settings operation is running. Credentials stay in this canonical private file and are never shown in menus, reviews, status, notifications, errors, logs, or completion metadata.
|
|
66
70
|
|
|
67
71
|
An existing private `pi-sync.local.json` containing a valid version 3 document is copied byte-for-byte to `pi-sync.json`; the old file remains as a recovery copy. If both paths exist, `pi-sync.json` wins and the other path remains untouched.
|
|
68
72
|
|
|
@@ -165,6 +169,12 @@ Safe agent-relative custom files or directories may also be included. Absolute p
|
|
|
165
169
|
|
|
166
170
|
An empty array is valid. It means no useful transfer is selected: **Sync now** reports the condition and does not claim that the setup is up to date. Unselected content remains unmanaged locally and is preserved when republishing existing remote snapshots.
|
|
167
171
|
|
|
172
|
+
The Included Content editor is a standard bounded multi-select backed by one in-memory draft. Toggles
|
|
173
|
+
never write settings. Leaving the editor opens an exact Include/Exclude review with **Save changes**,
|
|
174
|
+
**Discard changes**, and **Continue editing**; only reviewed Save publishes, while Continue preserves
|
|
175
|
+
the draft and Discard/cancellation preserves the settings bytes. RPC remains a read-only summary with
|
|
176
|
+
the manual `sync.include` path.
|
|
177
|
+
|
|
168
178
|
Adding `sessions` requires a privacy acknowledgement in interactive flows. Session JSONL can contain prompts, tool output, file paths, images, and secrets. Automatic apply protects the currently open session file; restart Pi or resume a pulled session to use newly synchronized conversations.
|
|
169
179
|
|
|
170
180
|
### Unsupported old settings and recovery
|
|
@@ -209,7 +219,9 @@ The menu is preferred, while deterministic routes remain available:
|
|
|
209
219
|
|
|
210
220
|
The former version 2 setup-addressing flag is rejected. Unknown flags, unknown commands, trailing values, and missing setup/snapshot values are rejected. Completion includes known setup names and preserves preceding command tokens.
|
|
211
221
|
|
|
212
|
-
TUI mode
|
|
222
|
+
TUI mode uses standard manager, settings, resource, and included-content screens plus specialized
|
|
223
|
+
secret, wizard, confirmation, and commit-aware loader components. RPC uses Pi's supported
|
|
224
|
+
dialog/notification protocol and does not gain settings mutation. Print and JSON modes never enter TUI-only screens; unsupported interactive routes reject or remain protocol-safe rather than relying on no-op output.
|
|
213
225
|
|
|
214
226
|
## 🔄 Backend and recovery model
|
|
215
227
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
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",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"typecheck": "tsc --noEmit"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"@narumitw/pi-tui-kit": "<1",
|
|
39
40
|
"fast-xml-parser": "^5.10.1",
|
|
40
41
|
"proper-lockfile": "^4.1.2"
|
|
41
42
|
},
|
package/src/config-file.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
-
constants as fsConstants,
|
|
4
3
|
mkdir,
|
|
5
4
|
mkdirSync,
|
|
6
5
|
realpath,
|
|
@@ -36,23 +35,51 @@ const LOCKFILE_FS_ADAPTER = {
|
|
|
36
35
|
utimesSync,
|
|
37
36
|
};
|
|
38
37
|
|
|
39
|
-
type
|
|
40
|
-
let
|
|
38
|
+
type PublishFile = (source: string, destination: string) => Promise<void>;
|
|
39
|
+
let publishConfigFile: PublishFile = publishFileWithoutReplacement;
|
|
40
|
+
let beforeConfigPublicationHook: () => Promise<void> = async () => undefined;
|
|
41
|
+
let afterMissingConfigReadProbeHook: () => Promise<void> = async () => undefined;
|
|
41
42
|
let afterReplacementInstalledHook: () => Promise<void> = async () => undefined;
|
|
42
43
|
let afterConfigQuarantinedHook: () => Promise<void> = async () => undefined;
|
|
43
44
|
|
|
44
45
|
export type FileIdentity = { dev: number; ino: number };
|
|
45
46
|
|
|
46
|
-
export async function
|
|
47
|
-
|
|
47
|
+
export async function withConfigFilePublicationForTest<T>(
|
|
48
|
+
publish: PublishFile,
|
|
48
49
|
run: () => Promise<T>,
|
|
49
50
|
): Promise<T> {
|
|
50
|
-
const previous =
|
|
51
|
-
|
|
51
|
+
const previous = publishConfigFile;
|
|
52
|
+
publishConfigFile = publish;
|
|
52
53
|
try {
|
|
53
54
|
return await run();
|
|
54
55
|
} finally {
|
|
55
|
-
|
|
56
|
+
publishConfigFile = previous;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function withConfigPublicationReadyHookForTest<T>(
|
|
61
|
+
hook: () => Promise<void>,
|
|
62
|
+
run: () => Promise<T>,
|
|
63
|
+
): Promise<T> {
|
|
64
|
+
const previous = beforeConfigPublicationHook;
|
|
65
|
+
beforeConfigPublicationHook = hook;
|
|
66
|
+
try {
|
|
67
|
+
return await run();
|
|
68
|
+
} finally {
|
|
69
|
+
beforeConfigPublicationHook = previous;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function withMissingConfigReadProbeHookForTest<T>(
|
|
74
|
+
hook: () => Promise<void>,
|
|
75
|
+
run: () => Promise<T>,
|
|
76
|
+
): Promise<T> {
|
|
77
|
+
const previous = afterMissingConfigReadProbeHook;
|
|
78
|
+
afterMissingConfigReadProbeHook = hook;
|
|
79
|
+
try {
|
|
80
|
+
return await run();
|
|
81
|
+
} finally {
|
|
82
|
+
afterMissingConfigReadProbeHook = previous;
|
|
56
83
|
}
|
|
57
84
|
}
|
|
58
85
|
|
|
@@ -99,10 +126,10 @@ export function legacyLocalConfigPath() {
|
|
|
99
126
|
}
|
|
100
127
|
|
|
101
128
|
export async function activeLocalConfigPath() {
|
|
102
|
-
|
|
103
|
-
|
|
129
|
+
const canonicalPath = localConfigPath();
|
|
130
|
+
const legacyPath = legacyLocalConfigPath();
|
|
131
|
+
return withLocalConfigReadLockIfNeeded(async () => {
|
|
104
132
|
if (await pathExists(canonicalPath)) return canonicalPath;
|
|
105
|
-
const legacyPath = legacyLocalConfigPath();
|
|
106
133
|
return (await pathExists(legacyPath)) ? legacyPath : canonicalPath;
|
|
107
134
|
});
|
|
108
135
|
}
|
|
@@ -117,9 +144,10 @@ export function consumeLocalConfigMigrationNotice() {
|
|
|
117
144
|
export async function readActiveLocalConfigDocumentForRepair(): Promise<
|
|
118
145
|
LocalConfigDocument | undefined
|
|
119
146
|
> {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
147
|
+
const canonicalPath = localConfigPath();
|
|
148
|
+
const legacyPath = legacyLocalConfigPath();
|
|
149
|
+
return withLocalConfigReadLockIfNeeded(async () => {
|
|
150
|
+
const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyPath;
|
|
123
151
|
const snapshot = await readConfigSnapshotIfExists(filePath);
|
|
124
152
|
return snapshot ? { path: filePath, ...snapshot } : undefined;
|
|
125
153
|
});
|
|
@@ -128,13 +156,22 @@ export async function readActiveLocalConfigDocumentForRepair(): Promise<
|
|
|
128
156
|
export async function readMigratingLocalConfigDocument(
|
|
129
157
|
validateForMigration: (settings: Record<string, unknown>) => void,
|
|
130
158
|
): Promise<LocalConfigDocument | undefined> {
|
|
131
|
-
return
|
|
159
|
+
return withLocalConfigReadLockIfNeeded(async () => {
|
|
132
160
|
const configPath = await prepareLocalConfigPath(validateForMigration);
|
|
133
161
|
const snapshot = await readConfigSnapshotIfExists(configPath);
|
|
134
162
|
return snapshot ? { path: configPath, ...snapshot } : undefined;
|
|
135
163
|
});
|
|
136
164
|
}
|
|
137
165
|
|
|
166
|
+
async function withLocalConfigReadLockIfNeeded<T>(read: () => Promise<T>): Promise<T> {
|
|
167
|
+
if ((await pathExists(localConfigPath())) || (await pathExists(legacyLocalConfigPath()))) {
|
|
168
|
+
return withLocalConfigFileLock(read);
|
|
169
|
+
}
|
|
170
|
+
await afterMissingConfigReadProbeHook();
|
|
171
|
+
if (await pathExists(configMutationLockPath())) return withLocalConfigFileLock(read);
|
|
172
|
+
return read();
|
|
173
|
+
}
|
|
174
|
+
|
|
138
175
|
export function updateLocalConfigDocument<T extends Record<string, unknown>>(
|
|
139
176
|
defaultValue: T,
|
|
140
177
|
update: (current: T) => T,
|
|
@@ -154,7 +191,7 @@ export function updateLocalConfigDocument<T extends Record<string, unknown>>(
|
|
|
154
191
|
signal?.throwIfAborted();
|
|
155
192
|
if (document && JSON.stringify(document.parsed) === JSON.stringify(next)) return next;
|
|
156
193
|
if (document) await replaceLocalConfigDocumentUnlocked(document, next);
|
|
157
|
-
else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next)
|
|
194
|
+
else await installPrivateConfigExclusively(localConfigPath(), serializedConfig(next));
|
|
158
195
|
return next;
|
|
159
196
|
});
|
|
160
197
|
}
|
|
@@ -163,7 +200,7 @@ export function createLocalConfigDocument(value: Record<string, unknown>) {
|
|
|
163
200
|
return withLocalConfigFileLock(async () => {
|
|
164
201
|
const bytes = serializedConfig(value);
|
|
165
202
|
try {
|
|
166
|
-
await installPrivateConfigExclusively(localConfigPath(), bytes
|
|
203
|
+
await installPrivateConfigExclusively(localConfigPath(), bytes);
|
|
167
204
|
} catch (error) {
|
|
168
205
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
169
206
|
throw new Error("Pi-sync settings were created concurrently; reopen settings and retry.");
|
|
@@ -190,7 +227,7 @@ async function replaceLocalConfigDocumentUnlocked(
|
|
|
190
227
|
if (!(await configDocumentStillMatches(document))) throw settingsChangedError();
|
|
191
228
|
let installed: FileIdentity;
|
|
192
229
|
try {
|
|
193
|
-
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes
|
|
230
|
+
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
|
|
194
231
|
} catch (error) {
|
|
195
232
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
196
233
|
throw new Error("Canonical settings were created concurrently; no settings were replaced.");
|
|
@@ -207,7 +244,7 @@ async function replaceLocalConfigDocumentUnlocked(
|
|
|
207
244
|
const quarantinePath = await claimCanonicalConfigDocument(document);
|
|
208
245
|
let installed: FileIdentity;
|
|
209
246
|
try {
|
|
210
|
-
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes
|
|
247
|
+
installed = await installPrivateConfigExclusively(canonicalPath, nextBytes);
|
|
211
248
|
} catch (error) {
|
|
212
249
|
await restoreQuarantinedConfig(canonicalPath, quarantinePath);
|
|
213
250
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
@@ -372,11 +409,7 @@ function parseConfigObject(bytes: Buffer, filePath: string) {
|
|
|
372
409
|
return parsed as Record<string, unknown>;
|
|
373
410
|
}
|
|
374
411
|
|
|
375
|
-
async function installPrivateConfigExclusively(
|
|
376
|
-
filePath: string,
|
|
377
|
-
bytes: Buffer,
|
|
378
|
-
allowCopyFallback = false,
|
|
379
|
-
) {
|
|
412
|
+
async function installPrivateConfigExclusively(filePath: string, bytes: Buffer) {
|
|
380
413
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
381
414
|
const temporaryPath = path.join(
|
|
382
415
|
path.dirname(filePath),
|
|
@@ -390,23 +423,34 @@ async function installPrivateConfigExclusively(
|
|
|
390
423
|
await handle.sync();
|
|
391
424
|
await handle.close();
|
|
392
425
|
handle = undefined;
|
|
393
|
-
|
|
394
|
-
await linkConfigFile(temporaryPath, filePath);
|
|
395
|
-
} catch (error) {
|
|
396
|
-
if (!allowCopyFallback || !isUnsupportedLinkError(error)) throw error;
|
|
397
|
-
await fs.copyFile(temporaryPath, filePath, fsConstants.COPYFILE_EXCL);
|
|
398
|
-
}
|
|
426
|
+
await publishConfigFile(temporaryPath, filePath);
|
|
399
427
|
const installed = await fs.lstat(filePath);
|
|
428
|
+
let publishedHandle: fs.FileHandle | undefined;
|
|
429
|
+
let publicationError: unknown;
|
|
400
430
|
try {
|
|
401
|
-
if (
|
|
402
|
-
|
|
431
|
+
if (installed.isSymbolicLink() || !installed.isFile()) {
|
|
432
|
+
throw new Error(`Published pi-sync settings are not a regular file: ${filePath}`);
|
|
433
|
+
}
|
|
434
|
+
publishedHandle = await fs.open(filePath, "r+");
|
|
435
|
+
const published = await publishedHandle.stat();
|
|
436
|
+
if (published.dev !== installed.dev || published.ino !== installed.ino) {
|
|
437
|
+
throw new Error(`Published pi-sync settings changed while opening: ${filePath}`);
|
|
438
|
+
}
|
|
439
|
+
if (process.platform !== "win32") await publishedHandle.chmod(0o600);
|
|
440
|
+
await publishedHandle.sync();
|
|
441
|
+
await syncParentDirectory(filePath);
|
|
403
442
|
} catch (error) {
|
|
443
|
+
publicationError = error;
|
|
444
|
+
} finally {
|
|
445
|
+
await publishedHandle?.close().catch(() => undefined);
|
|
446
|
+
}
|
|
447
|
+
if (publicationError) {
|
|
404
448
|
await quarantineAndRemoveConfigIfMatchesUnlocked(
|
|
405
449
|
filePath,
|
|
406
450
|
{ dev: installed.dev, ino: installed.ino },
|
|
407
451
|
bytes,
|
|
408
452
|
);
|
|
409
|
-
throw
|
|
453
|
+
throw publicationError;
|
|
410
454
|
}
|
|
411
455
|
return { dev: installed.dev, ino: installed.ino };
|
|
412
456
|
} finally {
|
|
@@ -446,11 +490,6 @@ function settingsChangedError() {
|
|
|
446
490
|
return new Error("pi-sync settings changed during migration; no settings were replaced.");
|
|
447
491
|
}
|
|
448
492
|
|
|
449
|
-
function isUnsupportedLinkError(error: unknown) {
|
|
450
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
451
|
-
return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EXDEV" || code === "EPERM";
|
|
452
|
-
}
|
|
453
|
-
|
|
454
493
|
async function configSnapshotStillMatches(filePath: string, snapshot: ConfigSnapshot) {
|
|
455
494
|
return fileIdentityAndContentsMatch(filePath, snapshot.identity, snapshot.bytes);
|
|
456
495
|
}
|
|
@@ -519,45 +558,32 @@ async function fileIdentityAndContentsMatch(
|
|
|
519
558
|
|
|
520
559
|
async function restoreQuarantinedConfig(filePath: string, quarantinePath: string) {
|
|
521
560
|
try {
|
|
522
|
-
await
|
|
523
|
-
await fs.
|
|
561
|
+
await renameFileWithoutReplacement(quarantinePath, filePath);
|
|
562
|
+
if (process.platform !== "win32") await fs.chmod(filePath, 0o600);
|
|
524
563
|
await syncParentDirectory(filePath);
|
|
525
564
|
return;
|
|
526
565
|
} catch (error) {
|
|
527
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST")
|
|
528
|
-
try {
|
|
529
|
-
await fs.copyFile(quarantinePath, filePath, fsConstants.COPYFILE_EXCL);
|
|
530
|
-
if (process.platform !== "win32") {
|
|
531
|
-
await fs.chmod(filePath, 0o600);
|
|
532
|
-
await fs.chmod(quarantinePath, 0o600);
|
|
533
|
-
}
|
|
534
|
-
await syncParentDirectory(filePath);
|
|
535
|
-
return;
|
|
536
|
-
} catch (copyError) {
|
|
537
|
-
if ((copyError as NodeJS.ErrnoException).code !== "EEXIST") return;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
566
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return;
|
|
540
567
|
}
|
|
541
568
|
try {
|
|
542
|
-
|
|
543
|
-
fs.readFile(filePath),
|
|
544
|
-
fs.readFile(quarantinePath),
|
|
545
|
-
]);
|
|
546
|
-
if (current.equals(quarantined)) await fs.rm(quarantinePath);
|
|
547
|
-
else if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
|
|
569
|
+
if (process.platform !== "win32") await fs.chmod(quarantinePath, 0o600);
|
|
548
570
|
await syncParentDirectory(filePath);
|
|
549
571
|
} catch {
|
|
550
572
|
// Preserve the quarantine rather than risk deleting settings that changed concurrently.
|
|
551
573
|
}
|
|
552
574
|
}
|
|
553
575
|
|
|
576
|
+
function configMutationLockPath() {
|
|
577
|
+
return `${localConfigPath()}.mutation-lock`;
|
|
578
|
+
}
|
|
579
|
+
|
|
554
580
|
export async function withLocalConfigFileLock<T>(run: () => Promise<T>): Promise<T> {
|
|
555
581
|
const configPath = localConfigPath();
|
|
556
582
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
557
583
|
let compromisedError: Error | undefined;
|
|
558
584
|
const release = await lockfile.lock(configPath, {
|
|
559
585
|
fs: LOCKFILE_FS_ADAPTER,
|
|
560
|
-
lockfilePath:
|
|
586
|
+
lockfilePath: configMutationLockPath(),
|
|
561
587
|
realpath: false,
|
|
562
588
|
stale: CONFIG_LOCK_STALE_MS,
|
|
563
589
|
update: CONFIG_LOCK_UPDATE_MS,
|
|
@@ -586,6 +612,18 @@ async function syncParentDirectory(filePath: string) {
|
|
|
586
612
|
}
|
|
587
613
|
}
|
|
588
614
|
|
|
615
|
+
async function publishFileWithoutReplacement(source: string, destination: string) {
|
|
616
|
+
await beforeConfigPublicationHook();
|
|
617
|
+
await renameFileWithoutReplacement(source, destination);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function renameFileWithoutReplacement(source: string, destination: string) {
|
|
621
|
+
if (await pathExists(destination)) {
|
|
622
|
+
throw Object.assign(new Error(`Settings already exist: ${destination}`), { code: "EEXIST" });
|
|
623
|
+
}
|
|
624
|
+
await fs.rename(source, destination);
|
|
625
|
+
}
|
|
626
|
+
|
|
589
627
|
async function pathExists(filePath: string) {
|
|
590
628
|
try {
|
|
591
629
|
await fs.lstat(filePath);
|
package/src/file-selection.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
4
|
-
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
5
4
|
import { agentDir, loadConfig, localConfigPath } from "./config.js";
|
|
6
5
|
import { updateSyncSetup } from "./settings-management.js";
|
|
7
6
|
import {
|
|
@@ -10,8 +9,6 @@ import {
|
|
|
10
9
|
syncIncludeSelection,
|
|
11
10
|
} from "./sync-policy.js";
|
|
12
11
|
|
|
13
|
-
const INCLUDED = "included";
|
|
14
|
-
const EXCLUDED = "excluded";
|
|
15
12
|
const BUILT_IN_PREFIX = "builtin:";
|
|
16
13
|
const CUSTOM_PREFIX = "custom:";
|
|
17
14
|
const SESSIONS_ID = "sessions";
|
|
@@ -53,11 +50,7 @@ export async function showFileSelection(
|
|
|
53
50
|
while (!signal?.aborted) {
|
|
54
51
|
await showDraftEditor(ctx, config.setupName, draft, customCandidates, signal);
|
|
55
52
|
if (signal?.aborted || sameDraft(original, draft)) return;
|
|
56
|
-
const choice = await ctx
|
|
57
|
-
formatDraftPreview(original, draft),
|
|
58
|
-
["Save changes", "Discard changes", "Continue editing"],
|
|
59
|
-
{ signal },
|
|
60
|
-
);
|
|
53
|
+
const choice = await showDraftReview(ctx, original, draft, signal);
|
|
61
54
|
if (signal?.aborted) return;
|
|
62
55
|
if (choice === "Continue editing") continue;
|
|
63
56
|
if (choice !== "Save changes") {
|
|
@@ -114,74 +107,96 @@ async function showDraftEditor(
|
|
|
114
107
|
customCandidates: string[],
|
|
115
108
|
signal?: AbortSignal,
|
|
116
109
|
) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
110
|
+
const menu = defineMenu<undefined, "editor", "toggle", ExtensionCommandContext>({
|
|
111
|
+
start: "editor",
|
|
112
|
+
screens: {
|
|
113
|
+
editor: () => ({
|
|
114
|
+
kind: "multiSelect",
|
|
115
|
+
title: `Included Content · ${safeTerminalText(setupName)}`,
|
|
116
|
+
lines: ["Draft only · leaving this screen opens Save, Discard, or Continue editing."],
|
|
117
|
+
viewportSize: 12,
|
|
118
|
+
items: [
|
|
119
|
+
...BUILT_IN_SYNC_ROOTS.map((relativePath) => ({
|
|
120
|
+
id: `${BUILT_IN_PREFIX}${relativePath}`,
|
|
121
|
+
label: relativePath,
|
|
122
|
+
description: relativePath.includes(".")
|
|
123
|
+
? `Sync the top-level ${relativePath} file when present.`
|
|
124
|
+
: `Recursively sync every safe file under ${relativePath}/.`,
|
|
125
|
+
selected: draft.builtIns.has(relativePath),
|
|
126
|
+
})),
|
|
127
|
+
...customCandidates.map((relativePath) => ({
|
|
128
|
+
id: `${CUSTOM_PREFIX}${relativePath}`,
|
|
129
|
+
label: safeTerminalText(relativePath),
|
|
130
|
+
description: "Additional safe agent-relative file or directory.",
|
|
131
|
+
selected: draft.custom.has(relativePath),
|
|
132
|
+
})),
|
|
133
|
+
{
|
|
134
|
+
id: SESSIONS_ID,
|
|
135
|
+
label: "sessions",
|
|
136
|
+
description:
|
|
137
|
+
"Session JSONL may contain prompts, tool output, paths, images, and secrets. Sync only to storage you trust.",
|
|
138
|
+
selected: draft.sessions,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
action: "toggle",
|
|
142
|
+
hint: "close",
|
|
143
|
+
doneLabel: "Review changes",
|
|
144
|
+
}),
|
|
134
145
|
},
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
currentValue: draft.custom.has(relativePath) ? INCLUDED : EXCLUDED,
|
|
140
|
-
values: [INCLUDED, EXCLUDED],
|
|
141
|
-
})),
|
|
142
|
-
];
|
|
143
|
-
|
|
144
|
-
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
145
|
-
const container = new Container();
|
|
146
|
-
let disposed = false;
|
|
147
|
-
const onAbort = () => done(undefined);
|
|
148
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
149
|
-
const title = new Text("", 1, 0);
|
|
150
|
-
const hint = new Text("", 1, 0);
|
|
151
|
-
const updateChrome = () => {
|
|
152
|
-
title.setText(
|
|
153
|
-
theme.fg("accent", theme.bold(`Included Content · ${safeTerminalText(setupName)}`)),
|
|
154
|
-
);
|
|
155
|
-
hint.setText(theme.fg("dim", "Draft only · Esc reviews Save, Discard, or Continue editing."));
|
|
156
|
-
};
|
|
157
|
-
updateChrome();
|
|
158
|
-
container.addChild(title);
|
|
159
|
-
const list = new SettingsList(
|
|
160
|
-
items,
|
|
161
|
-
Math.min(items.length + 2, 15),
|
|
162
|
-
getSettingsListTheme(),
|
|
163
|
-
(id, newValue) => updateDraft(draft, id, newValue === INCLUDED),
|
|
164
|
-
() => done(undefined),
|
|
165
|
-
);
|
|
166
|
-
container.addChild(list);
|
|
167
|
-
container.addChild(hint);
|
|
168
|
-
return {
|
|
169
|
-
render: (width: number) => container.render(width),
|
|
170
|
-
invalidate() {
|
|
171
|
-
updateChrome();
|
|
172
|
-
container.invalidate();
|
|
173
|
-
},
|
|
174
|
-
handleInput(data: string) {
|
|
175
|
-
if (disposed) return;
|
|
176
|
-
list.handleInput(data);
|
|
177
|
-
tui.requestRender();
|
|
146
|
+
actions: {
|
|
147
|
+
toggle: async ({ itemId, selected }) => {
|
|
148
|
+
updateDraft(draft, itemId, selected === true);
|
|
149
|
+
return { kind: "stay" };
|
|
178
150
|
},
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
await runMenu(ctx, menu, {
|
|
154
|
+
getState: () => undefined,
|
|
155
|
+
signal,
|
|
156
|
+
isCurrent: () => !signal?.aborted,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function showDraftReview(
|
|
161
|
+
ctx: ExtensionCommandContext,
|
|
162
|
+
original: SelectionDraft,
|
|
163
|
+
draft: SelectionDraft,
|
|
164
|
+
signal?: AbortSignal,
|
|
165
|
+
) {
|
|
166
|
+
let choice: "Save changes" | "Discard changes" | "Continue editing" | undefined;
|
|
167
|
+
const menu = defineMenu<undefined, "review", "choose", ExtensionCommandContext>({
|
|
168
|
+
start: "review",
|
|
169
|
+
screens: {
|
|
170
|
+
review: () => ({
|
|
171
|
+
kind: "actions",
|
|
172
|
+
title: "Review included-content changes",
|
|
173
|
+
lines: formatDraftPreview(original, draft).split("\n").slice(2),
|
|
174
|
+
items: [
|
|
175
|
+
{ id: "save", label: "Save changes", action: "choose" },
|
|
176
|
+
{ id: "discard", label: "Discard changes", action: "choose" },
|
|
177
|
+
{ id: "continue", label: "Continue editing", action: "choose" },
|
|
178
|
+
],
|
|
179
|
+
hint: "close",
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
actions: {
|
|
183
|
+
choose: async ({ itemId }) => {
|
|
184
|
+
choice =
|
|
185
|
+
itemId === "save"
|
|
186
|
+
? "Save changes"
|
|
187
|
+
: itemId === "continue"
|
|
188
|
+
? "Continue editing"
|
|
189
|
+
: "Discard changes";
|
|
190
|
+
return { kind: "close" };
|
|
182
191
|
},
|
|
183
|
-
}
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
await runMenu(ctx, menu, {
|
|
195
|
+
getState: () => undefined,
|
|
196
|
+
signal,
|
|
197
|
+
isCurrent: () => !signal?.aborted,
|
|
184
198
|
});
|
|
199
|
+
return choice;
|
|
185
200
|
}
|
|
186
201
|
|
|
187
202
|
function updateDraft(draft: SelectionDraft, id: string, included: boolean) {
|