toolcraft 0.0.24 → 0.0.26
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/dist/cli.js +11 -9
- package/dist/error-report.js +14 -11
- package/dist/redaction.d.ts +4 -0
- package/dist/redaction.js +70 -0
- package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.js +33 -9
- package/node_modules/@poe-code/config-mutations/dist/formats/json.d.ts +2 -1
- package/node_modules/@poe-code/config-mutations/dist/formats/json.js +36 -9
- package/node_modules/@poe-code/config-mutations/dist/types.d.ts +2 -0
- package/node_modules/@poe-code/design-system/dist/components/browser.js +1 -1
- package/node_modules/@poe-code/design-system/dist/explorer/actions.js +1 -1
- package/node_modules/@poe-code/design-system/dist/explorer/keymap.js +11 -1
- package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +64 -8
- package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +9 -11
- package/node_modules/@poe-code/design-system/dist/explorer/render/footer.js +18 -8
- package/node_modules/@poe-code/design-system/dist/explorer/render/header.js +11 -18
- package/node_modules/@poe-code/design-system/dist/explorer/render/index.js +2 -10
- package/node_modules/@poe-code/design-system/dist/explorer/render/list.js +32 -22
- package/node_modules/@poe-code/design-system/dist/explorer/render/modal.js +5 -9
- package/node_modules/@poe-code/design-system/dist/explorer/render/text.d.ts +12 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/text.js +81 -0
- package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +1 -0
- package/node_modules/@poe-code/design-system/dist/explorer/state.js +2 -0
- package/node_modules/@poe-code/design-system/dist/prompts/index.js +3 -3
- package/node_modules/@poe-code/process-runner/dist/docker/args.d.ts +1 -0
- package/node_modules/@poe-code/process-runner/dist/docker/args.js +11 -3
- package/node_modules/@poe-code/process-runner/dist/docker/docker-execution-env.js +170 -36
- package/node_modules/@poe-code/process-runner/dist/docker/docker-runner.js +66 -9
- package/node_modules/@poe-code/process-runner/dist/docker/env-file.d.ts +6 -0
- package/node_modules/@poe-code/process-runner/dist/docker/env-file.js +49 -0
- package/node_modules/@poe-code/process-runner/dist/host/host-runner.js +5 -4
- package/node_modules/@poe-code/process-runner/dist/types.d.ts +3 -0
- package/node_modules/@poe-code/process-runner/dist/workspace-transfer.d.ts +5 -1
- package/node_modules/@poe-code/process-runner/dist/workspace-transfer.js +29 -10
- package/node_modules/auth-store/dist/encrypted-file-store.d.ts +1 -0
- package/node_modules/auth-store/dist/encrypted-file-store.js +36 -2
- package/node_modules/auth-store/dist/keychain-store.js +20 -1
- package/node_modules/mcp-oauth/dist/client/auth-store-session-store.js +6 -3
- package/node_modules/tiny-mcp-client/dist/internal.d.ts +3 -0
- package/node_modules/tiny-mcp-client/dist/internal.js +39 -14
- package/node_modules/tiny-mcp-client/src/internal.ts +45 -17
- package/node_modules/tiny-mcp-client/src/mcp-client-sdk.test.ts +32 -0
- package/node_modules/tiny-mcp-client/src/transports.test.ts +68 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { invokeWithHumanInLoop } from "./human-in-loop/index.js";
|
|
|
9
9
|
import { resolveMcpProxies } from "./mcp-proxy.js";
|
|
10
10
|
import { getExpectedNumberDescription, isValidNumberSchemaValue } from "./number-schema.js";
|
|
11
11
|
import { findEntrypointPackageMetadata } from "./package-metadata.js";
|
|
12
|
+
import { redactHttpBody, redactHttpHeaderValue } from "./redaction.js";
|
|
12
13
|
import { renderResult } from "./renderer.js";
|
|
13
14
|
import { renderSourceSnippet } from "./source-snippet.js";
|
|
14
15
|
import { enableSourceMaps, formatDebugStack } from "./stack-trim.js";
|
|
@@ -2586,17 +2587,18 @@ function formatGraphQLErrorEnvelopeBody(body) {
|
|
|
2586
2587
|
.join("\n\n");
|
|
2587
2588
|
}
|
|
2588
2589
|
function formatHttpErrorBody(body) {
|
|
2589
|
-
|
|
2590
|
-
|
|
2590
|
+
const redactedBody = redactHttpBody(body);
|
|
2591
|
+
if (typeof redactedBody === "string") {
|
|
2592
|
+
return redactedBody;
|
|
2591
2593
|
}
|
|
2592
|
-
if (isProblemDetailsLike(
|
|
2593
|
-
return formatProblemDetailsBody(
|
|
2594
|
+
if (isProblemDetailsLike(redactedBody)) {
|
|
2595
|
+
return formatProblemDetailsBody(redactedBody);
|
|
2594
2596
|
}
|
|
2595
|
-
if (isGraphQLErrorEnvelopeLike(
|
|
2596
|
-
return formatGraphQLErrorEnvelopeBody(
|
|
2597
|
+
if (isGraphQLErrorEnvelopeLike(redactedBody)) {
|
|
2598
|
+
return formatGraphQLErrorEnvelopeBody(redactedBody);
|
|
2597
2599
|
}
|
|
2598
|
-
const serialized = JSON.stringify(
|
|
2599
|
-
return serialized === undefined ? String(
|
|
2600
|
+
const serialized = JSON.stringify(redactedBody, null, 2);
|
|
2601
|
+
return serialized === undefined ? String(redactedBody) : serialized;
|
|
2600
2602
|
}
|
|
2601
2603
|
function indentHttpErrorBlock(value) {
|
|
2602
2604
|
return value
|
|
@@ -2605,7 +2607,7 @@ function indentHttpErrorBlock(value) {
|
|
|
2605
2607
|
.join("\n");
|
|
2606
2608
|
}
|
|
2607
2609
|
function formatHttpHeaderValue(name, value) {
|
|
2608
|
-
return name
|
|
2610
|
+
return redactHttpHeaderValue(name, value);
|
|
2609
2611
|
}
|
|
2610
2612
|
function formatHttpErrorHeaders(headers) {
|
|
2611
2613
|
return Object.entries(headers).map(([name, value]) => ` ${name}: ${formatHttpHeaderValue(name, value)}`);
|
package/dist/error-report.js
CHANGED
|
@@ -6,9 +6,9 @@ import { CommanderError } from "commander";
|
|
|
6
6
|
import { ApprovalDeclinedError } from "./human-in-loop/types.js";
|
|
7
7
|
import { findProjectRoot } from "./mcp-proxy.js";
|
|
8
8
|
import { findPackageMetadata } from "./package-metadata.js";
|
|
9
|
+
import { isSensitiveName, redactHttpBody, redactHttpHeaderValue } from "./redaction.js";
|
|
9
10
|
import { UserError } from "./user-error.js";
|
|
10
11
|
const ERROR_REPORTS_ENV = "TOOLCRAFT_ERROR_REPORTS";
|
|
11
|
-
const DEFAULT_SENSITIVE_NAMES = ["password", "token", "apikey", "secret"];
|
|
12
12
|
function isPlainObject(value) {
|
|
13
13
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
14
|
}
|
|
@@ -116,10 +116,6 @@ function redactValue(value) {
|
|
|
116
116
|
}
|
|
117
117
|
return `<set, ${value.length} chars>`;
|
|
118
118
|
}
|
|
119
|
-
function isSensitiveName(name) {
|
|
120
|
-
const normalized = name.toLowerCase();
|
|
121
|
-
return DEFAULT_SENSITIVE_NAMES.some((candidate) => normalized.includes(candidate));
|
|
122
|
-
}
|
|
123
119
|
function schemaSecretValue(schema) {
|
|
124
120
|
const unwrapped = unwrapOptional(schema);
|
|
125
121
|
if (unwrapped.kind === "string" || unwrapped.kind === "number") {
|
|
@@ -208,8 +204,14 @@ function stableJson(value) {
|
|
|
208
204
|
return JSON.stringify(value, null, 2) ?? "undefined";
|
|
209
205
|
}
|
|
210
206
|
function redactStructuredErrorField(name, value) {
|
|
211
|
-
if (typeof value === "string"
|
|
212
|
-
|
|
207
|
+
if (typeof value === "string") {
|
|
208
|
+
const redactedHeaderValue = redactHttpHeaderValue(name, value);
|
|
209
|
+
if (redactedHeaderValue !== value) {
|
|
210
|
+
return redactedHeaderValue;
|
|
211
|
+
}
|
|
212
|
+
if (isSensitiveName(name)) {
|
|
213
|
+
return "<redacted>";
|
|
214
|
+
}
|
|
213
215
|
}
|
|
214
216
|
if (Array.isArray(value)) {
|
|
215
217
|
return value.map((entry) => redactStructuredErrorField(name, entry));
|
|
@@ -254,7 +256,7 @@ function formatStackChain(error) {
|
|
|
254
256
|
return lines.join("\n");
|
|
255
257
|
}
|
|
256
258
|
function formatHeaderValue(name, value) {
|
|
257
|
-
return name
|
|
259
|
+
return redactHttpHeaderValue(name, value);
|
|
258
260
|
}
|
|
259
261
|
function formatHeaders(headers) {
|
|
260
262
|
return Object.entries(headers)
|
|
@@ -262,10 +264,11 @@ function formatHeaders(headers) {
|
|
|
262
264
|
.join("\n");
|
|
263
265
|
}
|
|
264
266
|
function formatBody(body) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
const redactedBody = redactHttpBody(body);
|
|
268
|
+
if (typeof redactedBody === "string") {
|
|
269
|
+
return redactedBody;
|
|
267
270
|
}
|
|
268
|
-
return stableJson(
|
|
271
|
+
return stableJson(redactedBody);
|
|
269
272
|
}
|
|
270
273
|
function formatHttpTranscript(error) {
|
|
271
274
|
const requestLines = [
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function isSensitiveName(name: string): boolean;
|
|
2
|
+
export declare function redactSecretLikeFields(value: unknown, name?: string): unknown;
|
|
3
|
+
export declare function redactHttpBody(body: unknown): unknown;
|
|
4
|
+
export declare function redactHttpHeaderValue(name: string, value: string): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const REDACTED_VALUE = "<redacted>";
|
|
2
|
+
const SENSITIVE_NAME_PARTS = ["password", "token", "apikey", "secret"];
|
|
3
|
+
const AUTHORIZATION_HEADER_NAMES = new Set(["authorization", "proxyauthorization"]);
|
|
4
|
+
const SECRET_HEADER_NAMES = new Set(["cookie", "setcookie"]);
|
|
5
|
+
function isPlainObject(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function normalizeName(name) {
|
|
9
|
+
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
10
|
+
}
|
|
11
|
+
export function isSensitiveName(name) {
|
|
12
|
+
const normalized = normalizeName(name);
|
|
13
|
+
return SENSITIVE_NAME_PARTS.some((candidate) => normalized.includes(candidate));
|
|
14
|
+
}
|
|
15
|
+
function redactSecretLikeFieldsValue(value, name, seen) {
|
|
16
|
+
if (name.length > 0 && isSensitiveName(name)) {
|
|
17
|
+
return REDACTED_VALUE;
|
|
18
|
+
}
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
if (seen.has(value)) {
|
|
21
|
+
return "[Circular]";
|
|
22
|
+
}
|
|
23
|
+
seen.add(value);
|
|
24
|
+
return value.map((entry) => redactSecretLikeFieldsValue(entry, name, seen));
|
|
25
|
+
}
|
|
26
|
+
if (isPlainObject(value)) {
|
|
27
|
+
if (seen.has(value)) {
|
|
28
|
+
return "[Circular]";
|
|
29
|
+
}
|
|
30
|
+
seen.add(value);
|
|
31
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
32
|
+
key,
|
|
33
|
+
redactSecretLikeFieldsValue(entry, key, seen)
|
|
34
|
+
]));
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
export function redactSecretLikeFields(value, name = "") {
|
|
39
|
+
return redactSecretLikeFieldsValue(value, name, new WeakSet());
|
|
40
|
+
}
|
|
41
|
+
function parseJsonObjectOrArray(value) {
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(trimmed);
|
|
48
|
+
return isPlainObject(parsed) || Array.isArray(parsed) ? parsed : undefined;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function redactHttpBody(body) {
|
|
55
|
+
if (typeof body === "string") {
|
|
56
|
+
const parsed = parseJsonObjectOrArray(body);
|
|
57
|
+
return parsed === undefined ? body : redactSecretLikeFields(parsed);
|
|
58
|
+
}
|
|
59
|
+
return redactSecretLikeFields(body);
|
|
60
|
+
}
|
|
61
|
+
export function redactHttpHeaderValue(name, value) {
|
|
62
|
+
const normalized = normalizeName(name);
|
|
63
|
+
if (AUTHORIZATION_HEADER_NAMES.has(normalized)) {
|
|
64
|
+
return "Bearer ****";
|
|
65
|
+
}
|
|
66
|
+
if (SECRET_HEADER_NAMES.has(normalized) || isSensitiveName(name)) {
|
|
67
|
+
return REDACTED_VALUE;
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { renderTemplate } from "@poe-code/design-system";
|
|
3
3
|
import { getConfigFormat, detectFormat } from "../formats/index.js";
|
|
4
|
+
import { cloneConfigObject, setConfigEntry } from "../formats/object.js";
|
|
4
5
|
import { resolvePath } from "./path-utils.js";
|
|
5
6
|
import { isNotFound, readFileIfExists, pathExists, createTimestamp } from "../fs-utils.js";
|
|
6
7
|
// ============================================================================
|
|
@@ -120,7 +121,7 @@ function pruneKeysByPrefix(table, prefix) {
|
|
|
120
121
|
const result = {};
|
|
121
122
|
for (const [key, value] of Object.entries(table)) {
|
|
122
123
|
if (!key.startsWith(prefix)) {
|
|
123
|
-
result
|
|
124
|
+
setConfigEntry(result, key, value);
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
return result;
|
|
@@ -129,25 +130,44 @@ function isConfigObject(value) {
|
|
|
129
130
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
130
131
|
}
|
|
131
132
|
function mergeWithPruneByPrefix(base, patch, pruneByPrefix) {
|
|
132
|
-
const result =
|
|
133
|
+
const result = cloneConfigObject(base);
|
|
133
134
|
const prefixMap = pruneByPrefix ?? {};
|
|
134
135
|
for (const [key, value] of Object.entries(patch)) {
|
|
136
|
+
if (value === undefined) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
135
139
|
const current = result[key];
|
|
136
140
|
const prefix = prefixMap[key];
|
|
137
141
|
if (isConfigObject(current) && isConfigObject(value)) {
|
|
138
142
|
if (prefix) {
|
|
139
143
|
const pruned = pruneKeysByPrefix(current, prefix);
|
|
140
|
-
result
|
|
144
|
+
setConfigEntry(result, key, mergePrunedConfigObject(pruned, value));
|
|
141
145
|
}
|
|
142
146
|
else {
|
|
143
|
-
result
|
|
147
|
+
setConfigEntry(result, key, mergeWithPruneByPrefix(current, value, prefixMap));
|
|
144
148
|
}
|
|
145
149
|
continue;
|
|
146
150
|
}
|
|
147
|
-
result
|
|
151
|
+
setConfigEntry(result, key, value);
|
|
148
152
|
}
|
|
149
153
|
return result;
|
|
150
154
|
}
|
|
155
|
+
function mergePrunedConfigObject(base, patch) {
|
|
156
|
+
const result = cloneConfigObject(base);
|
|
157
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
158
|
+
if (value === undefined) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
setConfigEntry(result, key, value);
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
function serializeConfigUpdate(format, rawContent, current, next) {
|
|
166
|
+
if (rawContent !== null && format.serializeUpdate) {
|
|
167
|
+
return format.serializeUpdate(rawContent, current, next);
|
|
168
|
+
}
|
|
169
|
+
return format.serialize(next);
|
|
170
|
+
}
|
|
151
171
|
// ============================================================================
|
|
152
172
|
// Apply Mutation
|
|
153
173
|
// ============================================================================
|
|
@@ -480,6 +500,7 @@ async function applyConfigMerge(mutation, context, options) {
|
|
|
480
500
|
}
|
|
481
501
|
const format = getConfigFormat(formatName);
|
|
482
502
|
const rawContent = await readFileIfExists(context.fs, targetPath);
|
|
503
|
+
let preserveContent = rawContent;
|
|
483
504
|
let current;
|
|
484
505
|
try {
|
|
485
506
|
current = rawContent === null ? {} : format.parse(rawContent);
|
|
@@ -490,9 +511,10 @@ async function applyConfigMerge(mutation, context, options) {
|
|
|
490
511
|
await backupInvalidDocument(context, targetPath, rawContent);
|
|
491
512
|
}
|
|
492
513
|
current = {};
|
|
514
|
+
preserveContent = null;
|
|
493
515
|
}
|
|
494
516
|
const value = resolveValue(mutation.value, options);
|
|
495
|
-
//
|
|
517
|
+
// Keep prefix pruning on the same proto-safe object-write path as normal merges.
|
|
496
518
|
let merged;
|
|
497
519
|
if (mutation.pruneByPrefix) {
|
|
498
520
|
merged = mergeWithPruneByPrefix(current, value, mutation.pruneByPrefix);
|
|
@@ -500,7 +522,7 @@ async function applyConfigMerge(mutation, context, options) {
|
|
|
500
522
|
else {
|
|
501
523
|
merged = format.merge(current, value);
|
|
502
524
|
}
|
|
503
|
-
const serialized = format
|
|
525
|
+
const serialized = serializeConfigUpdate(format, preserveContent, current, merged);
|
|
504
526
|
const changed = serialized !== rawContent;
|
|
505
527
|
if (changed && !context.dryRun) {
|
|
506
528
|
await writeAtomically(context, targetPath, serialized);
|
|
@@ -570,7 +592,7 @@ async function applyConfigPrune(mutation, context, options) {
|
|
|
570
592
|
details
|
|
571
593
|
};
|
|
572
594
|
}
|
|
573
|
-
const serialized = format
|
|
595
|
+
const serialized = serializeConfigUpdate(format, rawContent, current, result);
|
|
574
596
|
if (!context.dryRun) {
|
|
575
597
|
await writeAtomically(context, targetPath, serialized);
|
|
576
598
|
}
|
|
@@ -593,6 +615,7 @@ async function applyConfigTransform(mutation, context, options) {
|
|
|
593
615
|
}
|
|
594
616
|
const format = getConfigFormat(formatName);
|
|
595
617
|
const rawContent = await readFileIfExists(context.fs, targetPath);
|
|
618
|
+
let preserveContent = rawContent;
|
|
596
619
|
let current;
|
|
597
620
|
try {
|
|
598
621
|
current = rawContent === null ? {} : format.parse(rawContent);
|
|
@@ -602,6 +625,7 @@ async function applyConfigTransform(mutation, context, options) {
|
|
|
602
625
|
await backupInvalidDocument(context, targetPath, rawContent);
|
|
603
626
|
}
|
|
604
627
|
current = {};
|
|
628
|
+
preserveContent = null;
|
|
605
629
|
}
|
|
606
630
|
const { content: transformed, changed } = mutation.transform(current, options);
|
|
607
631
|
if (!changed) {
|
|
@@ -626,7 +650,7 @@ async function applyConfigTransform(mutation, context, options) {
|
|
|
626
650
|
details
|
|
627
651
|
};
|
|
628
652
|
}
|
|
629
|
-
const serialized = format
|
|
653
|
+
const serialized = serializeConfigUpdate(format, preserveContent, current, transformed);
|
|
630
654
|
if (!context.dryRun) {
|
|
631
655
|
await writeAtomically(context, targetPath, serialized);
|
|
632
656
|
}
|
|
@@ -27,5 +27,6 @@ declare function mergePreservingComments(content: string, patch: ConfigObject):
|
|
|
27
27
|
* @returns The modified JSON content with comments preserved
|
|
28
28
|
*/
|
|
29
29
|
declare function removeAtPath(content: string, path: (string | number)[]): string;
|
|
30
|
-
|
|
30
|
+
declare function serializeUpdate(content: string, current: ConfigObject, next: ConfigObject): string;
|
|
31
|
+
export { detectIndent, modifyAtPath, mergePreservingComments, removeAtPath, serializeUpdate };
|
|
31
32
|
export declare const jsonFormat: ConfigFormat;
|
|
@@ -48,6 +48,9 @@ function merge(base, patch) {
|
|
|
48
48
|
}
|
|
49
49
|
return result;
|
|
50
50
|
}
|
|
51
|
+
function configValuesEqual(left, right) {
|
|
52
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
53
|
+
}
|
|
51
54
|
function prune(obj, shape) {
|
|
52
55
|
let changed = false;
|
|
53
56
|
const result = cloneConfigObject(obj);
|
|
@@ -116,14 +119,8 @@ function modifyAtPath(content, path, value) {
|
|
|
116
119
|
* @returns The modified JSON content with comments preserved
|
|
117
120
|
*/
|
|
118
121
|
function mergePreservingComments(content, patch) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (value === undefined) {
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
result = modifyAtPath(result, [key], value);
|
|
125
|
-
}
|
|
126
|
-
return result;
|
|
122
|
+
const current = parse(content);
|
|
123
|
+
return serializeUpdate(content || "{}", current, merge(current, patch));
|
|
127
124
|
}
|
|
128
125
|
/**
|
|
129
126
|
* Remove a key from JSON content while preserving comments and formatting.
|
|
@@ -135,10 +132,40 @@ function mergePreservingComments(content, patch) {
|
|
|
135
132
|
function removeAtPath(content, path) {
|
|
136
133
|
return modifyAtPath(content, path, undefined);
|
|
137
134
|
}
|
|
138
|
-
|
|
135
|
+
function serializeUpdate(content, current, next) {
|
|
136
|
+
let result = content || "{}";
|
|
137
|
+
result = applyObjectUpdate(result, [], current, next);
|
|
138
|
+
if (!result.endsWith("\n")) {
|
|
139
|
+
result += "\n";
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
function applyObjectUpdate(content, path, current, next) {
|
|
144
|
+
let result = content;
|
|
145
|
+
for (const key of Object.keys(current)) {
|
|
146
|
+
if (!hasConfigEntry(next, key)) {
|
|
147
|
+
result = removeAtPath(result, [...path, key]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (const [key, nextValue] of Object.entries(next)) {
|
|
151
|
+
const nextPath = [...path, key];
|
|
152
|
+
const hasCurrent = hasConfigEntry(current, key);
|
|
153
|
+
const currentValue = hasCurrent ? current[key] : undefined;
|
|
154
|
+
if (hasCurrent && isConfigObject(currentValue) && isConfigObject(nextValue)) {
|
|
155
|
+
result = applyObjectUpdate(result, nextPath, currentValue, nextValue);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!hasCurrent || !configValuesEqual(currentValue, nextValue)) {
|
|
159
|
+
result = modifyAtPath(result, nextPath, nextValue);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
export { detectIndent, modifyAtPath, mergePreservingComments, removeAtPath, serializeUpdate };
|
|
139
165
|
export const jsonFormat = {
|
|
140
166
|
parse,
|
|
141
167
|
serialize,
|
|
168
|
+
serializeUpdate,
|
|
142
169
|
merge,
|
|
143
170
|
prune
|
|
144
171
|
};
|
|
@@ -35,6 +35,8 @@ export interface ConfigFormat {
|
|
|
35
35
|
parse(content: string): ConfigObject;
|
|
36
36
|
/** Serialize object to string (with consistent formatting) */
|
|
37
37
|
serialize(obj: ConfigObject): string;
|
|
38
|
+
/** Serialize an update against original content while preserving comments when possible */
|
|
39
|
+
serializeUpdate?(content: string, current: ConfigObject, next: ConfigObject): string;
|
|
38
40
|
/** Deep merge patch into base, returning new object */
|
|
39
41
|
merge(base: ConfigObject, patch: ConfigObject): ConfigObject;
|
|
40
42
|
/** Remove keys matching shape from object, returning new object */
|
|
@@ -10,7 +10,7 @@ function browserCommand(url, platform) {
|
|
|
10
10
|
return { command: "open", args: [url] };
|
|
11
11
|
}
|
|
12
12
|
if (platform === "win32") {
|
|
13
|
-
return { command: "
|
|
13
|
+
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
14
14
|
}
|
|
15
15
|
return { command: "xdg-open", args: [url] };
|
|
16
16
|
}
|
|
@@ -32,7 +32,7 @@ function currentDetailItem(state) {
|
|
|
32
32
|
return state.detail.items?.[state.detail.cursor];
|
|
33
33
|
}
|
|
34
34
|
function selectedRows(state, fallback) {
|
|
35
|
-
if (state.selected.size === 0) {
|
|
35
|
+
if (!state.multiSelect || state.selected.size === 0) {
|
|
36
36
|
return fallback.id === "" ? [] : [fallback];
|
|
37
37
|
}
|
|
38
38
|
return state.rows.filter((row) => state.selected.has(row.id));
|
|
@@ -45,14 +45,24 @@ const baseBuiltinCommands = [
|
|
|
45
45
|
"extendSelectionDown"
|
|
46
46
|
];
|
|
47
47
|
const reorderCommands = ["reorderUp", "reorderDown"];
|
|
48
|
+
const selectionCommands = new Set([
|
|
49
|
+
"toggleSelect",
|
|
50
|
+
"selectAll",
|
|
51
|
+
"clearSelection",
|
|
52
|
+
"extendSelectionUp",
|
|
53
|
+
"extendSelectionDown"
|
|
54
|
+
]);
|
|
48
55
|
const reservedActionIds = new Set(["quit"]);
|
|
49
56
|
export function resolveBindings(config, defaults = {}) {
|
|
50
|
-
const
|
|
57
|
+
const baseCommands = config.reorder === undefined
|
|
51
58
|
? baseBuiltinCommands
|
|
52
59
|
: [
|
|
53
60
|
...baseBuiltinCommands.filter((command) => command !== "extendSelectionUp" && command !== "extendSelectionDown"),
|
|
54
61
|
...reorderCommands
|
|
55
62
|
];
|
|
63
|
+
const commands = config.multiSelect === false
|
|
64
|
+
? baseCommands.filter((command) => !selectionCommands.has(command))
|
|
65
|
+
: baseCommands;
|
|
56
66
|
const commandBindings = new Map();
|
|
57
67
|
const flatBindings = new Map();
|
|
58
68
|
const targetKeys = new Map();
|
|
@@ -101,6 +101,9 @@ function stepKey(state, key, runtimeHandles) {
|
|
|
101
101
|
if (isBackspace(key)) {
|
|
102
102
|
return updateFilter(state, state.filter.slice(0, -1));
|
|
103
103
|
}
|
|
104
|
+
if (!state.multiSelect && isSelectionSpace(key)) {
|
|
105
|
+
return mark(state, 0);
|
|
106
|
+
}
|
|
104
107
|
if (isPrintable(key)) {
|
|
105
108
|
return updateFilter(state, `${state.filter}${key.ch}`);
|
|
106
109
|
}
|
|
@@ -170,7 +173,7 @@ function resize(state, cols, rows) {
|
|
|
170
173
|
return mark(state, 0);
|
|
171
174
|
}
|
|
172
175
|
return {
|
|
173
|
-
state: { ...state, size, layout, dirty: REGION_ALL },
|
|
176
|
+
state: clampDetailScroll({ ...state, size, layout, dirty: REGION_ALL }),
|
|
174
177
|
effects: NO_EFFECTS
|
|
175
178
|
};
|
|
176
179
|
}
|
|
@@ -186,7 +189,7 @@ function rowsLoaded(state, rows) {
|
|
|
186
189
|
const filtered = matches.map((match) => match.index);
|
|
187
190
|
const matchPositions = createMatchPositions(matches);
|
|
188
191
|
const cursor = clamp(state.cursor, 0, Math.max(0, filtered.length - 1));
|
|
189
|
-
const selected = pruneSelection(state.selected, rows);
|
|
192
|
+
const selected = state.multiSelect ? pruneSelection(state.selected, rows) : new Set();
|
|
190
193
|
const detail = resetDetailForCursor(state, rows, filtered, cursor);
|
|
191
194
|
const modal = modalStillValid(state.modal, rows);
|
|
192
195
|
if (state.modal?.kind === "confirm" && modal === null) {
|
|
@@ -239,8 +242,9 @@ function detailItemRendered(state, rowId, token, itemIndex, content) {
|
|
|
239
242
|
return mark(state, 0);
|
|
240
243
|
}
|
|
241
244
|
const items = state.detail.items.map((item, index) => index === itemIndex ? { ...item, renderedContent: content } : item);
|
|
245
|
+
const detail = { ...state.detail, items };
|
|
242
246
|
return {
|
|
243
|
-
state: { ...state, detail
|
|
247
|
+
state: clampDetailScroll({ ...state, detail, dirty: REGION_DETAIL }),
|
|
244
248
|
effects: NO_EFFECTS
|
|
245
249
|
};
|
|
246
250
|
}
|
|
@@ -400,6 +404,9 @@ function confirmKey(state, runtimeHandles) {
|
|
|
400
404
|
return dispatchPrimary(state, runtimeHandles);
|
|
401
405
|
}
|
|
402
406
|
function toggleSelect(state) {
|
|
407
|
+
if (!state.multiSelect) {
|
|
408
|
+
return mark(state, 0);
|
|
409
|
+
}
|
|
403
410
|
const row = currentRow(state);
|
|
404
411
|
if (row === undefined) {
|
|
405
412
|
return mark(state, 0);
|
|
@@ -414,6 +421,9 @@ function toggleSelect(state) {
|
|
|
414
421
|
return selectionChanged(state, selected);
|
|
415
422
|
}
|
|
416
423
|
function selectAll(state) {
|
|
424
|
+
if (!state.multiSelect) {
|
|
425
|
+
return mark(state, 0);
|
|
426
|
+
}
|
|
417
427
|
const selected = new Set(state.selected);
|
|
418
428
|
for (const index of state.filtered) {
|
|
419
429
|
const row = state.rows[index];
|
|
@@ -430,13 +440,14 @@ function clearSelection(state) {
|
|
|
430
440
|
return selectionChanged(state, new Set());
|
|
431
441
|
}
|
|
432
442
|
function selectionChanged(state, selected) {
|
|
433
|
-
|
|
443
|
+
const normalized = state.multiSelect ? selected : new Set();
|
|
444
|
+
if (setsEqual(state.selected, normalized)) {
|
|
434
445
|
return mark(state, 0);
|
|
435
446
|
}
|
|
436
447
|
const next = {
|
|
437
448
|
...state,
|
|
438
|
-
selected,
|
|
439
|
-
actionState: recomputeActionState({ ...state, selected }),
|
|
449
|
+
selected: normalized,
|
|
450
|
+
actionState: recomputeActionState({ ...state, selected: normalized }),
|
|
440
451
|
dirty: REGION_LIST | REGION_FOOTER
|
|
441
452
|
};
|
|
442
453
|
return { state: next, effects: NO_EFFECTS };
|
|
@@ -445,7 +456,7 @@ function detailScroll(state, delta) {
|
|
|
445
456
|
if (state.focused !== "detail") {
|
|
446
457
|
return mark(state, 0);
|
|
447
458
|
}
|
|
448
|
-
const scroll =
|
|
459
|
+
const scroll = clamp(state.detail.scroll + delta, 0, maxDetailScroll(state));
|
|
449
460
|
if (scroll === state.detail.scroll) {
|
|
450
461
|
return mark(state, 0);
|
|
451
462
|
}
|
|
@@ -454,7 +465,49 @@ function detailScroll(state, delta) {
|
|
|
454
465
|
effects: NO_EFFECTS
|
|
455
466
|
};
|
|
456
467
|
}
|
|
468
|
+
function clampDetailScroll(state) {
|
|
469
|
+
const scroll = clamp(state.detail.scroll, 0, maxDetailScroll(state));
|
|
470
|
+
if (scroll === state.detail.scroll) {
|
|
471
|
+
return state;
|
|
472
|
+
}
|
|
473
|
+
return { ...state, detail: { ...state.detail, scroll } };
|
|
474
|
+
}
|
|
475
|
+
function maxDetailScroll(state) {
|
|
476
|
+
const items = state.detail.items;
|
|
477
|
+
if (items === null || items.length === 0) {
|
|
478
|
+
return 0;
|
|
479
|
+
}
|
|
480
|
+
if (items.length === 1 && items[0]?.title === undefined) {
|
|
481
|
+
const visibleHeight = detailBodyHeight(state);
|
|
482
|
+
if (visibleHeight <= 0) {
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
return Math.max(0, detailContentLineCount(items[0]) - visibleHeight);
|
|
486
|
+
}
|
|
487
|
+
return Math.max(0, items.length - 1);
|
|
488
|
+
}
|
|
489
|
+
function detailContentLineCount(item) {
|
|
490
|
+
return (item.renderedContent ?? "").split("\n").length;
|
|
491
|
+
}
|
|
492
|
+
function detailBodyHeight(state) {
|
|
493
|
+
if (state.layout === "too-narrow" || state.layout === "narrow-list-only") {
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
const rows = normalizeSize(state.size.rows);
|
|
497
|
+
const footerHeight = rows > 0 ? Math.min(1, rows) : 0;
|
|
498
|
+
const headerHeight = Math.min(3, Math.max(0, rows - footerHeight));
|
|
499
|
+
const contentHeight = Math.max(0, rows - headerHeight - footerHeight);
|
|
500
|
+
if (state.layout === "narrow-vertical") {
|
|
501
|
+
const listHeight = Math.ceil(contentHeight / 2);
|
|
502
|
+
const detailHeight = contentHeight - listHeight;
|
|
503
|
+
return Math.max(0, detailHeight - 1);
|
|
504
|
+
}
|
|
505
|
+
return contentHeight;
|
|
506
|
+
}
|
|
457
507
|
function extendSelection(state, delta) {
|
|
508
|
+
if (!state.multiSelect) {
|
|
509
|
+
return moveCursor(state, delta);
|
|
510
|
+
}
|
|
458
511
|
const moved = moveCursor(state, delta);
|
|
459
512
|
const row = currentRow(moved.state);
|
|
460
513
|
if (row === undefined) {
|
|
@@ -646,7 +699,7 @@ function actionSource(state, action) {
|
|
|
646
699
|
return state.actionState.get(action.id)?.source ?? "row";
|
|
647
700
|
}
|
|
648
701
|
function selectedRows(state) {
|
|
649
|
-
if (state.selected.size === 0) {
|
|
702
|
+
if (!state.multiSelect || state.selected.size === 0) {
|
|
650
703
|
const row = currentRow(state);
|
|
651
704
|
return row === undefined ? [] : [row];
|
|
652
705
|
}
|
|
@@ -718,6 +771,9 @@ function isPrintable(key) {
|
|
|
718
771
|
function isBackspace(key) {
|
|
719
772
|
return key.name === "backspace" || key.name === "delete";
|
|
720
773
|
}
|
|
774
|
+
function isSelectionSpace(key) {
|
|
775
|
+
return key.name === "space" || key.ch === " ";
|
|
776
|
+
}
|
|
721
777
|
function isConfirmYes(key) {
|
|
722
778
|
return key.ch === "y" || key.ch === "Y";
|
|
723
779
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getExplorerStyles } from "../theme.js";
|
|
2
|
+
import { fitToWidth } from "./text.js";
|
|
2
3
|
export function renderDetail(state, screen, layout) {
|
|
3
4
|
const rect = layout.detail;
|
|
4
5
|
const styles = getExplorerStyles();
|
|
@@ -35,7 +36,8 @@ function renderDetailBody(state, screen, rect, row) {
|
|
|
35
36
|
function renderListMode(state, screen, rect, items, row) {
|
|
36
37
|
const styles = getExplorerStyles();
|
|
37
38
|
let y = 0;
|
|
38
|
-
|
|
39
|
+
const start = clamp(state.detail.scroll, 0, Math.max(0, items.length - 1));
|
|
40
|
+
for (let index = start; index < items.length && y < rect.height; index += 1) {
|
|
39
41
|
const item = items[index];
|
|
40
42
|
const cursor = index === state.detail.cursor;
|
|
41
43
|
const title = item.title ?? item.id;
|
|
@@ -60,7 +62,9 @@ function renderListMode(state, screen, rect, items, row) {
|
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
function renderBlob(screen, rect, text, scroll) {
|
|
63
|
-
const
|
|
65
|
+
const allLines = text.split("\n");
|
|
66
|
+
const start = clamp(scroll, 0, Math.max(0, allLines.length - rect.height));
|
|
67
|
+
const lines = allLines.slice(start);
|
|
64
68
|
for (let row = 0; row < rect.height; row += 1) {
|
|
65
69
|
writeLine(screen, rect, row, lines[row] ?? "");
|
|
66
70
|
}
|
|
@@ -86,14 +90,8 @@ function writeLine(screen, rect, row, text, style = {}) {
|
|
|
86
90
|
if (row < 0 || row >= rect.height) {
|
|
87
91
|
return;
|
|
88
92
|
}
|
|
89
|
-
screen.put(rect.x, rect.y + row,
|
|
93
|
+
screen.put(rect.x, rect.y + row, fitToWidth(text, rect.width, rect.x), style);
|
|
90
94
|
}
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
return "";
|
|
94
|
-
}
|
|
95
|
-
if (text.length <= width) {
|
|
96
|
-
return text;
|
|
97
|
-
}
|
|
98
|
-
return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
|
|
95
|
+
function clamp(value, min, max) {
|
|
96
|
+
return Math.min(max, Math.max(min, value));
|
|
99
97
|
}
|