@narumitw/pi-sync 0.31.0 → 0.35.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 +159 -236
- package/package.json +11 -3
- package/src/backend-factory.ts +20 -0
- package/src/command.ts +39 -39
- package/src/config-errors.ts +7 -0
- package/src/config-file.ts +156 -62
- package/src/config.ts +605 -476
- package/src/file-selection.ts +121 -140
- package/src/git-backend.ts +832 -0
- package/src/git-config.ts +136 -0
- package/src/git-runner.ts +276 -0
- package/src/git-storage.ts +288 -0
- package/src/git-ui.ts +307 -0
- package/src/manager-helpers.ts +64 -0
- package/src/manager-ui.ts +537 -542
- package/src/s3-backend.ts +498 -0
- package/src/s3-client.ts +147 -64
- package/src/s3-credentials-ui.ts +92 -0
- package/src/secret-input.ts +199 -0
- package/src/settings-management.ts +191 -277
- package/src/settings-ui.ts +94 -115
- package/src/setup-switch.ts +150 -0
- package/src/snapshot-apply.ts +13 -7
- package/src/snapshot-codec.ts +48 -0
- package/src/snapshot-transaction.ts +3 -1
- package/src/snapshot.ts +63 -32
- package/src/storage-connections-ui.ts +308 -0
- package/src/sync-backend.ts +97 -0
- package/src/sync-format.ts +31 -33
- package/src/sync-operations.ts +912 -0
- package/src/sync-policy.ts +137 -61
- package/src/sync-setups-ui.ts +177 -0
- package/src/sync-state.ts +48 -48
- package/src/sync.ts +180 -739
- package/src/types.ts +177 -53
- package/src/webdav-backend.ts +678 -0
- package/src/webdav-client.ts +390 -0
- package/src/webdav-config.ts +92 -0
- package/src/webdav-ui.ts +421 -0
- package/src/target-switch.ts +0 -119
package/src/s3-client.ts
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
import { createHash, createHmac } from "node:crypto";
|
|
2
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
3
|
import { encodeKey, posixJoin } from "./paths.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { RemoteObject, ResolvedS3Backend } from "./types.js";
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const MAX_JSON_RESPONSE_BYTES = 1024 * 1024;
|
|
7
|
+
const MAX_SNAPSHOT_RESPONSE_BYTES = 256 * 1024 * 1024;
|
|
8
|
+
const MAX_ERROR_RESPONSE_BYTES = 64 * 1024;
|
|
9
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
7
10
|
|
|
8
11
|
function iso8601Basic(date: Date) {
|
|
9
12
|
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
function snapshotIncludesSessions(snapshot: Snapshot) {
|
|
13
|
-
return (
|
|
14
|
-
snapshot.syncSessions === true ||
|
|
15
|
-
snapshot.files.some((file) => file.path.startsWith("sessions/"))
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
15
|
function isCloudflareR2Endpoint(endpoint: string | undefined) {
|
|
20
16
|
const value = endpoint?.trim();
|
|
21
17
|
if (!value) return false;
|
|
@@ -29,16 +25,29 @@ function isCloudflareR2Endpoint(endpoint: string | undefined) {
|
|
|
29
25
|
}
|
|
30
26
|
}
|
|
31
27
|
|
|
28
|
+
export class S3ObjectAlreadyExistsError extends Error {
|
|
29
|
+
constructor(readonly key: string) {
|
|
30
|
+
super(`S3 object already exists: ${key}`);
|
|
31
|
+
this.name = "S3ObjectAlreadyExistsError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
32
35
|
export class S3Client {
|
|
33
|
-
private config:
|
|
36
|
+
private config: ResolvedS3Backend;
|
|
34
37
|
private endpoint: URL;
|
|
35
38
|
private signal?: AbortSignal;
|
|
39
|
+
private requestTimeoutMs: number;
|
|
36
40
|
private omitSessionTokenAfterRejection = false;
|
|
37
41
|
|
|
38
|
-
constructor(
|
|
42
|
+
constructor(
|
|
43
|
+
config: ResolvedS3Backend,
|
|
44
|
+
signal?: AbortSignal,
|
|
45
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
46
|
+
) {
|
|
39
47
|
this.config = config;
|
|
40
|
-
this.endpoint = new URL(config.endpoint);
|
|
48
|
+
this.endpoint = new URL(config.profile.endpoint);
|
|
41
49
|
this.signal = signal;
|
|
50
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
async getJson<T>(key: string): Promise<RemoteObject<T>> {
|
|
@@ -47,8 +56,10 @@ export class S3Client {
|
|
|
47
56
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
48
57
|
const object = await this.request("GET", key);
|
|
49
58
|
if (object.status === 404) return { missing: true };
|
|
50
|
-
if (!object.ok)
|
|
51
|
-
|
|
59
|
+
if (!object.ok) {
|
|
60
|
+
throw new Error(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`);
|
|
61
|
+
}
|
|
62
|
+
const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response");
|
|
52
63
|
// R2 can intermittently return an empty 200 body (read-after-write
|
|
53
64
|
// inconsistency on a long-lived keep-alive connection), which makes
|
|
54
65
|
// response.json() throw "JSON Parse error: Unexpected EOF" under Bun.
|
|
@@ -75,8 +86,14 @@ export class S3Client {
|
|
|
75
86
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
76
87
|
const object = await this.request("GET", key);
|
|
77
88
|
if (object.status === 404) return { missing: true };
|
|
78
|
-
if (!object.ok)
|
|
79
|
-
|
|
89
|
+
if (!object.ok) {
|
|
90
|
+
throw new Error(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`);
|
|
91
|
+
}
|
|
92
|
+
const buffer = await readBoundedBuffer(
|
|
93
|
+
object,
|
|
94
|
+
MAX_SNAPSHOT_RESPONSE_BYTES,
|
|
95
|
+
"S3 snapshot response",
|
|
96
|
+
);
|
|
80
97
|
// R2 can intermittently return an empty 200 body on a long-lived
|
|
81
98
|
// keep-alive connection (same root cause as the getJson retry
|
|
82
99
|
// above). getBuffer is only used for snapshot .json.gz payloads,
|
|
@@ -100,11 +117,21 @@ export class S3Client {
|
|
|
100
117
|
await this.putBuffer(key, body, "application/json");
|
|
101
118
|
}
|
|
102
119
|
|
|
103
|
-
async putBuffer(
|
|
120
|
+
async putBuffer(
|
|
121
|
+
key: string,
|
|
122
|
+
body: Buffer,
|
|
123
|
+
contentType: string,
|
|
124
|
+
options: { ifAbsent?: boolean } = {},
|
|
125
|
+
) {
|
|
104
126
|
const headers: Record<string, string> = { "content-type": contentType };
|
|
127
|
+
if (options.ifAbsent) headers["if-none-match"] = "*";
|
|
105
128
|
const response = await this.request("PUT", key, body, headers);
|
|
106
|
-
if (
|
|
107
|
-
throw new
|
|
129
|
+
if (options.ifAbsent && response.status === 412) {
|
|
130
|
+
throw new S3ObjectAlreadyExistsError(key);
|
|
131
|
+
}
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error(`S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`);
|
|
134
|
+
}
|
|
108
135
|
}
|
|
109
136
|
|
|
110
137
|
private async request(
|
|
@@ -114,26 +141,37 @@ export class S3Client {
|
|
|
114
141
|
extraHeaders: Record<string, string> = {},
|
|
115
142
|
) {
|
|
116
143
|
const url = new URL(this.endpoint.toString());
|
|
117
|
-
url.pathname = posixJoin(url.pathname, this.config.bucket, encodeKey(key));
|
|
144
|
+
url.pathname = posixJoin(url.pathname, this.config.destination.bucket, encodeKey(key));
|
|
118
145
|
const send = async (sessionToken: string | undefined) => {
|
|
146
|
+
const transportSignal = this.transportSignal();
|
|
119
147
|
const headers = await signedHeaders({
|
|
120
148
|
method,
|
|
121
149
|
url,
|
|
122
150
|
body,
|
|
123
151
|
extraHeaders,
|
|
124
|
-
accessKeyId: this.config.accessKeyId,
|
|
125
|
-
secretAccessKey: this.config.secretAccessKey,
|
|
152
|
+
accessKeyId: this.config.profile.accessKeyId,
|
|
153
|
+
secretAccessKey: this.config.profile.secretAccessKey,
|
|
126
154
|
sessionToken,
|
|
127
|
-
region: this.config.region,
|
|
128
|
-
});
|
|
129
|
-
return fetch(url, {
|
|
130
|
-
method,
|
|
131
|
-
headers,
|
|
132
|
-
body: body ? new Uint8Array(body) : undefined,
|
|
133
|
-
signal: this.signal,
|
|
155
|
+
region: this.config.profile.region,
|
|
134
156
|
});
|
|
157
|
+
try {
|
|
158
|
+
return await fetch(url, {
|
|
159
|
+
method,
|
|
160
|
+
headers,
|
|
161
|
+
body: body ? new Uint8Array(body) : undefined,
|
|
162
|
+
signal: transportSignal,
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
166
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
167
|
+
throw new Error("S3 request timed out.", { cause: error });
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`S3 request failed: ${this.redact(errorMessage(error))}`, { cause: error });
|
|
170
|
+
}
|
|
135
171
|
};
|
|
136
|
-
const sessionToken = this.omitSessionTokenAfterRejection
|
|
172
|
+
const sessionToken = this.omitSessionTokenAfterRejection
|
|
173
|
+
? undefined
|
|
174
|
+
: this.config.profile.sessionToken;
|
|
137
175
|
const response = await send(sessionToken);
|
|
138
176
|
if (!(await this.shouldRetryWithoutSessionToken(response, sessionToken))) return response;
|
|
139
177
|
|
|
@@ -142,20 +180,97 @@ export class S3Client {
|
|
|
142
180
|
return retry;
|
|
143
181
|
}
|
|
144
182
|
|
|
183
|
+
private transportSignal() {
|
|
184
|
+
const timeout = AbortSignal.timeout(this.requestTimeoutMs);
|
|
185
|
+
return this.signal ? AbortSignal.any([this.signal, timeout]) : timeout;
|
|
186
|
+
}
|
|
187
|
+
|
|
145
188
|
private async shouldRetryWithoutSessionToken(
|
|
146
189
|
response: Response,
|
|
147
190
|
sessionToken: string | undefined,
|
|
148
191
|
) {
|
|
149
192
|
if (
|
|
150
193
|
!sessionToken ||
|
|
151
|
-
!isCloudflareR2Endpoint(this.config.endpoint) ||
|
|
194
|
+
!isCloudflareR2Endpoint(this.config.profile.endpoint) ||
|
|
152
195
|
response.ok ||
|
|
153
196
|
response.status !== 400
|
|
154
197
|
) {
|
|
155
198
|
return false;
|
|
156
199
|
}
|
|
157
|
-
return isSecurityTokenInvalidArgument(
|
|
200
|
+
return isSecurityTokenInvalidArgument(
|
|
201
|
+
await readBoundedText(response.clone(), MAX_ERROR_RESPONSE_BYTES, "S3 error response"),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async readErrorText(response: Response) {
|
|
206
|
+
try {
|
|
207
|
+
return this.redact(
|
|
208
|
+
await readBoundedText(response, MAX_ERROR_RESPONSE_BYTES, "S3 error response"),
|
|
209
|
+
);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return this.redact(errorMessage(error));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private redact(value: string) {
|
|
216
|
+
let redacted = value;
|
|
217
|
+
let endpointUsername: string | undefined;
|
|
218
|
+
let endpointPassword: string | undefined;
|
|
219
|
+
let endpointQueryValues: string[] = [];
|
|
220
|
+
try {
|
|
221
|
+
const endpoint = new URL(this.config.profile.endpoint);
|
|
222
|
+
endpointUsername = endpoint.username;
|
|
223
|
+
endpointPassword = endpoint.password;
|
|
224
|
+
endpointQueryValues = [...endpoint.searchParams.values()];
|
|
225
|
+
} catch {
|
|
226
|
+
// The constructor already validates normal runtime endpoints.
|
|
227
|
+
}
|
|
228
|
+
for (const secret of [
|
|
229
|
+
this.config.profile.accessKeyId,
|
|
230
|
+
this.config.profile.secretAccessKey,
|
|
231
|
+
this.config.profile.sessionToken,
|
|
232
|
+
endpointUsername,
|
|
233
|
+
endpointPassword,
|
|
234
|
+
...endpointQueryValues,
|
|
235
|
+
]) {
|
|
236
|
+
if (secret) redacted = redacted.replaceAll(secret, "[REDACTED]");
|
|
237
|
+
}
|
|
238
|
+
return redacted;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function readBoundedText(response: Response, limit: number, label: string) {
|
|
243
|
+
return (await readBoundedBuffer(response, limit, label)).toString("utf8");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function readBoundedBuffer(response: Response, limit: number, label: string) {
|
|
247
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
248
|
+
if (Number.isFinite(contentLength) && contentLength > limit) {
|
|
249
|
+
throw new Error(`${label} exceeds the ${limit}-byte limit.`);
|
|
158
250
|
}
|
|
251
|
+
if (!response.body) return Buffer.alloc(0);
|
|
252
|
+
const reader = response.body.getReader();
|
|
253
|
+
const chunks: Buffer[] = [];
|
|
254
|
+
let total = 0;
|
|
255
|
+
try {
|
|
256
|
+
while (true) {
|
|
257
|
+
const { done, value } = await reader.read();
|
|
258
|
+
if (done) break;
|
|
259
|
+
total += value.byteLength;
|
|
260
|
+
if (total > limit) {
|
|
261
|
+
await reader.cancel().catch(() => undefined);
|
|
262
|
+
throw new Error(`${label} exceeds the ${limit}-byte limit.`);
|
|
263
|
+
}
|
|
264
|
+
chunks.push(Buffer.from(value));
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
reader.releaseLock();
|
|
268
|
+
}
|
|
269
|
+
return Buffer.concat(chunks, total);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function errorMessage(error: unknown) {
|
|
273
|
+
return error instanceof Error ? error.message : String(error);
|
|
159
274
|
}
|
|
160
275
|
|
|
161
276
|
async function signedHeaders(input: {
|
|
@@ -217,38 +332,6 @@ function sha256(value: Buffer) {
|
|
|
217
332
|
return createHash("sha256").update(value).digest("hex");
|
|
218
333
|
}
|
|
219
334
|
|
|
220
|
-
export function latestKey(config: SyncConfig) {
|
|
221
|
-
return posixJoin(profilePrefix(config), "latest.json");
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export function historyKey(config: SyncConfig) {
|
|
225
|
-
return posixJoin(profilePrefix(config), "history.json");
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export function snapshotKey(config: SyncConfig, id: string) {
|
|
229
|
-
return posixJoin(profilePrefix(config), "snapshots", `${id}.json.gz`);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export function profilePrefix(config: SyncConfig) {
|
|
233
|
-
return posixJoin(config.prefix, "profiles", config.profile);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export function pointerFor(
|
|
237
|
-
config: SyncConfig,
|
|
238
|
-
snapshot: Snapshot,
|
|
239
|
-
checksum: string,
|
|
240
|
-
): LatestPointer {
|
|
241
|
-
return {
|
|
242
|
-
version: VERSION,
|
|
243
|
-
profile: config.profile,
|
|
244
|
-
snapshot: snapshot.id,
|
|
245
|
-
sha256: checksum,
|
|
246
|
-
createdAt: snapshot.createdAt,
|
|
247
|
-
machine: snapshot.machine,
|
|
248
|
-
syncSessions: snapshotIncludesSessions(snapshot),
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
|
|
252
335
|
function lowercaseKeys(value: Record<string, string>) {
|
|
253
336
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key.toLowerCase(), item]));
|
|
254
337
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { promptSecret } from "./secret-input.js";
|
|
3
|
+
|
|
4
|
+
export interface ChosenS3Credentials {
|
|
5
|
+
profileFields: { accessKeyId?: string; secretAccessKey?: string };
|
|
6
|
+
summary: string;
|
|
7
|
+
ready: boolean;
|
|
8
|
+
replace?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function chooseS3CredentialUpdate(
|
|
12
|
+
ctx: ExtensionCommandContext,
|
|
13
|
+
profile: Record<string, unknown>,
|
|
14
|
+
signal?: AbortSignal,
|
|
15
|
+
) {
|
|
16
|
+
const hasStored =
|
|
17
|
+
typeof profile.accessKeyId === "string" && typeof profile.secretAccessKey === "string";
|
|
18
|
+
if (hasStored) {
|
|
19
|
+
const action = await ctx.ui.select(
|
|
20
|
+
"Credentials",
|
|
21
|
+
["Keep current credentials", "Change credential source", "Cancel"],
|
|
22
|
+
{ signal },
|
|
23
|
+
);
|
|
24
|
+
throwIfAborted(signal);
|
|
25
|
+
if (!action || action === "Cancel") return undefined;
|
|
26
|
+
if (action === "Keep current credentials") {
|
|
27
|
+
return { profileFields: {}, summary: "Unchanged (values hidden)", ready: true };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const selected = await chooseS3Credentials(ctx, signal);
|
|
31
|
+
return selected ? { ...selected, replace: true } : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function applyS3CredentialUpdate(
|
|
35
|
+
profile: Record<string, unknown>,
|
|
36
|
+
credentials: ChosenS3Credentials,
|
|
37
|
+
) {
|
|
38
|
+
const next = { ...profile };
|
|
39
|
+
if (credentials.replace) {
|
|
40
|
+
delete next.accessKeyId;
|
|
41
|
+
delete next.secretAccessKey;
|
|
42
|
+
delete next.sessionToken;
|
|
43
|
+
}
|
|
44
|
+
return { ...next, ...credentials.profileFields };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function chooseS3Credentials(
|
|
48
|
+
ctx: ExtensionCommandContext,
|
|
49
|
+
signal?: AbortSignal,
|
|
50
|
+
): Promise<ChosenS3Credentials | undefined> {
|
|
51
|
+
const choice = await ctx.ui.select(
|
|
52
|
+
"Credentials\n\nCredentials are stored in the private pi-sync settings file. Secret values are masked during input and never shown afterward.",
|
|
53
|
+
["Store credentials privately", "Cancel"],
|
|
54
|
+
{ signal },
|
|
55
|
+
);
|
|
56
|
+
throwIfAborted(signal);
|
|
57
|
+
if (choice !== "Store credentials privately") return undefined;
|
|
58
|
+
const accessKeyId = await requiredCredentialInput(ctx, "Access key ID", "access-key-id", signal);
|
|
59
|
+
if (!accessKeyId) return undefined;
|
|
60
|
+
const secretAccessKey = await promptSecret(ctx, "Secret access key", { signal });
|
|
61
|
+
throwIfAborted(signal);
|
|
62
|
+
if (secretAccessKey === undefined) return undefined;
|
|
63
|
+
return {
|
|
64
|
+
profileFields: { accessKeyId, secretAccessKey },
|
|
65
|
+
summary: "Stored privately (values hidden)",
|
|
66
|
+
ready: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function requiredCredentialInput(
|
|
71
|
+
ctx: ExtensionCommandContext,
|
|
72
|
+
title: string,
|
|
73
|
+
placeholder: string,
|
|
74
|
+
signal?: AbortSignal,
|
|
75
|
+
) {
|
|
76
|
+
const value = await ctx.ui.input(title, placeholder, { signal });
|
|
77
|
+
throwIfAborted(signal);
|
|
78
|
+
if (value === undefined) return undefined;
|
|
79
|
+
const normalized = value.trim();
|
|
80
|
+
if (!normalized) {
|
|
81
|
+
ctx.ui.notify(`${title} is required.`, "warning");
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return normalized.includes("<") || normalized.includes(">") ? undefined : normalized;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function throwIfAborted(signal?: AbortSignal) {
|
|
88
|
+
if (!signal?.aborted) return;
|
|
89
|
+
throw signal.reason instanceof Error
|
|
90
|
+
? signal.reason
|
|
91
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
92
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
CURSOR_MARKER,
|
|
4
|
+
decodeKittyPrintable,
|
|
5
|
+
type Focusable,
|
|
6
|
+
type KeybindingsManager,
|
|
7
|
+
Text,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
const MASK = "•";
|
|
12
|
+
|
|
13
|
+
export async function promptSecret(
|
|
14
|
+
ctx: ExtensionCommandContext,
|
|
15
|
+
title: string,
|
|
16
|
+
options: { required?: boolean; signal?: AbortSignal } = { required: true },
|
|
17
|
+
) {
|
|
18
|
+
if (ctx.mode !== "tui" || options.signal?.aborted) return undefined;
|
|
19
|
+
const value = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
20
|
+
const heading = new Text("", 0, 0);
|
|
21
|
+
const hint = new Text("", 0, 0);
|
|
22
|
+
const applyTheme = () => {
|
|
23
|
+
heading.setText(theme.fg("accent", theme.bold(title)));
|
|
24
|
+
hint.setText(theme.fg("dim", "Enter save • Escape cancel • Input is hidden"));
|
|
25
|
+
};
|
|
26
|
+
applyTheme();
|
|
27
|
+
const input = new MaskedInput(keybindings);
|
|
28
|
+
let settled = false;
|
|
29
|
+
const finish = (result: string | undefined) => {
|
|
30
|
+
if (settled) return;
|
|
31
|
+
settled = true;
|
|
32
|
+
done(result);
|
|
33
|
+
};
|
|
34
|
+
const onAbort = () => finish(undefined);
|
|
35
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
36
|
+
const component: Focusable & {
|
|
37
|
+
render(width: number): string[];
|
|
38
|
+
invalidate(): void;
|
|
39
|
+
handleInput(data: string): void;
|
|
40
|
+
dispose(): void;
|
|
41
|
+
} = {
|
|
42
|
+
get focused() {
|
|
43
|
+
return input.focused;
|
|
44
|
+
},
|
|
45
|
+
set focused(focused: boolean) {
|
|
46
|
+
input.focused = focused;
|
|
47
|
+
},
|
|
48
|
+
render(width: number) {
|
|
49
|
+
const safeWidth = Math.max(1, width);
|
|
50
|
+
return [
|
|
51
|
+
...heading.render(safeWidth),
|
|
52
|
+
...input.render(safeWidth),
|
|
53
|
+
...hint.render(safeWidth),
|
|
54
|
+
].map((line) => truncateToWidth(line, safeWidth));
|
|
55
|
+
},
|
|
56
|
+
invalidate() {
|
|
57
|
+
applyTheme();
|
|
58
|
+
heading.invalidate();
|
|
59
|
+
input.invalidate();
|
|
60
|
+
hint.invalidate();
|
|
61
|
+
},
|
|
62
|
+
handleInput(data: string) {
|
|
63
|
+
if (keybindings.matches(data, "tui.select.cancel")) finish(undefined);
|
|
64
|
+
else if (keybindings.matches(data, "tui.input.submit")) finish(input.getValue());
|
|
65
|
+
else input.handleInput(data);
|
|
66
|
+
tui.requestRender();
|
|
67
|
+
},
|
|
68
|
+
dispose() {
|
|
69
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
70
|
+
input.clear();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
return component;
|
|
74
|
+
});
|
|
75
|
+
if (value === undefined) return undefined;
|
|
76
|
+
if (options.required !== false && value.length === 0) {
|
|
77
|
+
ctx.ui.notify(`${title} is required.`, "warning");
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class MaskedInput implements Focusable {
|
|
84
|
+
focused = false;
|
|
85
|
+
private value: string[] = [];
|
|
86
|
+
private cursor = 0;
|
|
87
|
+
private paste = "";
|
|
88
|
+
private pasting = false;
|
|
89
|
+
|
|
90
|
+
constructor(private readonly keybindings: KeybindingsManager) {}
|
|
91
|
+
|
|
92
|
+
getValue() {
|
|
93
|
+
return this.value.join("");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
handleInput(data: string) {
|
|
97
|
+
if (data.includes("\u001b[200~")) {
|
|
98
|
+
this.pasting = true;
|
|
99
|
+
this.paste = "";
|
|
100
|
+
data = data.replace("\u001b[200~", "");
|
|
101
|
+
}
|
|
102
|
+
if (this.pasting) {
|
|
103
|
+
this.paste += data;
|
|
104
|
+
const end = this.paste.indexOf("\u001b[201~");
|
|
105
|
+
if (end < 0) return;
|
|
106
|
+
const pasted = this.paste
|
|
107
|
+
.slice(0, end)
|
|
108
|
+
.replace(/[\r\n]/gu, "")
|
|
109
|
+
.replace(/\t/gu, " ");
|
|
110
|
+
this.insert(pasted);
|
|
111
|
+
const remaining = this.paste.slice(end + 6);
|
|
112
|
+
this.paste = "";
|
|
113
|
+
this.pasting = false;
|
|
114
|
+
if (remaining) this.handleInput(remaining);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
|
|
118
|
+
if (this.cursor > 0) this.value.splice(--this.cursor, 1);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (this.keybindings.matches(data, "tui.editor.deleteCharForward")) {
|
|
122
|
+
if (this.cursor < this.value.length) this.value.splice(this.cursor, 1);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (this.keybindings.matches(data, "tui.editor.cursorLeft")) {
|
|
126
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (this.keybindings.matches(data, "tui.editor.cursorRight")) {
|
|
130
|
+
this.cursor = Math.min(this.value.length, this.cursor + 1);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (this.keybindings.matches(data, "tui.editor.cursorLineStart")) {
|
|
134
|
+
this.cursor = 0;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (this.keybindings.matches(data, "tui.editor.cursorLineEnd")) {
|
|
138
|
+
this.cursor = this.value.length;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (this.keybindings.matches(data, "tui.editor.deleteToLineStart")) {
|
|
142
|
+
this.value.splice(0, this.cursor);
|
|
143
|
+
this.cursor = 0;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (this.keybindings.matches(data, "tui.editor.deleteToLineEnd")) {
|
|
147
|
+
this.value.splice(this.cursor);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const printable = decodeKittyPrintable(data) ?? data;
|
|
151
|
+
if (!hasControlCharacter(printable)) this.insert(printable);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
render(width: number) {
|
|
155
|
+
const prompt = "> ";
|
|
156
|
+
const available = width - prompt.length;
|
|
157
|
+
if (available <= 0) return [truncateToWidth(prompt, Math.max(1, width))];
|
|
158
|
+
const contentWidth = Math.max(0, available - 1);
|
|
159
|
+
let start = 0;
|
|
160
|
+
if (this.value.length > contentWidth) {
|
|
161
|
+
start = Math.max(
|
|
162
|
+
0,
|
|
163
|
+
Math.min(this.cursor - Math.floor(contentWidth / 2), this.value.length - contentWidth),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const end = Math.min(this.value.length, start + contentWidth);
|
|
167
|
+
const visibleCursor = Math.max(0, Math.min(this.cursor - start, end - start));
|
|
168
|
+
const masks = Array.from({ length: end - start }, () => MASK);
|
|
169
|
+
const before = masks.slice(0, visibleCursor).join("");
|
|
170
|
+
const atCursor = visibleCursor < masks.length ? MASK : " ";
|
|
171
|
+
const after = masks.slice(visibleCursor + (visibleCursor < masks.length ? 1 : 0)).join("");
|
|
172
|
+
const marker = this.focused ? CURSOR_MARKER : "";
|
|
173
|
+
const line = `${prompt}${before}${marker}\u001b[7m${atCursor}\u001b[27m${after}`;
|
|
174
|
+
return [truncateToWidth(line, width, "")];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
invalidate() {}
|
|
178
|
+
|
|
179
|
+
clear() {
|
|
180
|
+
this.value.fill("");
|
|
181
|
+
this.value = [];
|
|
182
|
+
this.paste = "";
|
|
183
|
+
this.cursor = 0;
|
|
184
|
+
this.pasting = false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private insert(value: string) {
|
|
188
|
+
const characters = Array.from(value);
|
|
189
|
+
this.value.splice(this.cursor, 0, ...characters);
|
|
190
|
+
this.cursor += characters.length;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function hasControlCharacter(value: string) {
|
|
195
|
+
return [...value].some((character) => {
|
|
196
|
+
const code = character.codePointAt(0) ?? 0;
|
|
197
|
+
return code < 0x20 || (code >= 0x7f && code <= 0x9f);
|
|
198
|
+
});
|
|
199
|
+
}
|