@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 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 **More…**. Secondary screens provide **Back**; Escape closes the flow. Destructive and externally visible operations show exact previews and confirmations.
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 this file with mode `0600`. Credentials stay in this canonical private file and are never shown in menus, reviews, status, notifications, errors, logs, or completion metadata.
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 provides custom components. RPC uses Pi's supported dialog/notification protocol. Print and JSON modes never enter TUI-only screens; unsupported interactive routes reject or remain protocol-safe rather than relying on no-op output.
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.34.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
  },
@@ -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 LinkFile = (source: string, destination: string) => Promise<void>;
40
- let linkConfigFile: LinkFile = (source, destination) => fs.link(source, destination);
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 withConfigFileLinkForTest<T>(
47
- link: LinkFile,
47
+ export async function withConfigFilePublicationForTest<T>(
48
+ publish: PublishFile,
48
49
  run: () => Promise<T>,
49
50
  ): Promise<T> {
50
- const previous = linkConfigFile;
51
- linkConfigFile = link;
51
+ const previous = publishConfigFile;
52
+ publishConfigFile = publish;
52
53
  try {
53
54
  return await run();
54
55
  } finally {
55
- linkConfigFile = previous;
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
- return withLocalConfigFileLock(async () => {
103
- const canonicalPath = localConfigPath();
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
- return withLocalConfigFileLock(async () => {
121
- const canonicalPath = localConfigPath();
122
- const filePath = (await pathExists(canonicalPath)) ? canonicalPath : legacyLocalConfigPath();
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 withLocalConfigFileLock(async () => {
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), true);
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, true);
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, true);
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, true);
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
- try {
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 (process.platform !== "win32") await fs.chmod(filePath, 0o600);
402
- await syncParentDirectory(filePath).catch(() => undefined);
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 error;
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 linkConfigFile(quarantinePath, filePath);
523
- await fs.rm(quarantinePath);
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
- const [current, quarantined] = await Promise.all([
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: `${configPath}.mutation-lock`,
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);
@@ -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 { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
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.ui.select(
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 items: SettingItem[] = [
118
- ...BUILT_IN_SYNC_ROOTS.map((relativePath) => ({
119
- id: `${BUILT_IN_PREFIX}${relativePath}`,
120
- label: relativePath,
121
- description: relativePath.includes(".")
122
- ? `Sync the top-level ${relativePath} file when present.`
123
- : `Recursively sync every safe file under ${relativePath}/.`,
124
- currentValue: draft.builtIns.has(relativePath) ? INCLUDED : EXCLUDED,
125
- values: [INCLUDED, EXCLUDED],
126
- })),
127
- {
128
- id: SESSIONS_ID,
129
- label: "sessions",
130
- description:
131
- "Session JSONL may contain prompts, tool output, paths, images, and secrets. Sync only to storage you trust.",
132
- currentValue: draft.sessions ? INCLUDED : EXCLUDED,
133
- values: [INCLUDED, EXCLUDED],
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
- ...customCandidates.map((relativePath) => ({
136
- id: `${CUSTOM_PREFIX}${relativePath}`,
137
- label: safeTerminalText(relativePath),
138
- description: "Additional safe agent-relative file or directory.",
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
- dispose() {
180
- disposed = true;
181
- signal?.removeEventListener("abort", onAbort);
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) {