@narumitw/pi-statusline 0.21.0 → 0.23.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 +143 -96
- package/package.json +3 -4
- package/src/commands.ts +118 -0
- package/src/extension-status.ts +29 -32
- package/src/git-status.ts +11 -3
- package/src/render.ts +103 -65
- package/src/settings.ts +491 -109
- package/src/statusline.ts +51 -19
- package/src/tokyo-night.ts +180 -0
- package/src/types.ts +67 -0
- package/presets/classic.ts +0 -55
- package/presets/tokyo-night.ts +0 -84
- package/presets/types.ts +0 -39
- /package/{presets → src}/ansi.ts +0 -0
package/src/settings.ts
CHANGED
|
@@ -1,13 +1,43 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
linkSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
3
11
|
import { dirname, join } from "node:path";
|
|
4
|
-
import process from "node:process";
|
|
5
12
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import
|
|
13
|
+
import {
|
|
14
|
+
type ConfigSegmentName,
|
|
15
|
+
DENSITIES,
|
|
16
|
+
LINE_BREAK_SEGMENT_NAME,
|
|
17
|
+
PALETTE_NAMES,
|
|
18
|
+
SEGMENT_NAMES,
|
|
19
|
+
SEPARATOR_NAMES,
|
|
20
|
+
type SegmentName,
|
|
21
|
+
type StatuslineConfig,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
export const SETTINGS_FILE_NAME = "pi-statusline.json";
|
|
25
|
+
const LEGACY_SETTINGS_FILE_NAME = "pi-statusline-settings.json";
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_EXTENSION_STATUS_ICONS: Record<string, string> = {
|
|
28
|
+
"chrome-devtools": "🌐",
|
|
29
|
+
"codex-usage": "📊",
|
|
30
|
+
caffeinate: "💊",
|
|
31
|
+
firecrawl: "🔥",
|
|
32
|
+
"github-pr": "🔎",
|
|
33
|
+
goal: "🎯",
|
|
34
|
+
lsp: "🧰",
|
|
35
|
+
"plan-mode": "📝",
|
|
36
|
+
pisync: "🔄",
|
|
37
|
+
subagents: "🧑🤝🧑",
|
|
38
|
+
"unknown-error-retry": "🔁",
|
|
39
|
+
};
|
|
7
40
|
|
|
8
|
-
const SETTINGS_FILE = "pi-statusline.json";
|
|
9
|
-
const LEGACY_SETTINGS_FILE = "pi-statusline-settings.json";
|
|
10
|
-
const DEFAULT_PRESET: StatuslinePresetName = "tokyo-night";
|
|
11
41
|
const DEFAULT_SEGMENTS: SegmentName[] = [
|
|
12
42
|
"brand",
|
|
13
43
|
"provider",
|
|
@@ -21,94 +51,482 @@ const DEFAULT_SEGMENTS: SegmentName[] = [
|
|
|
21
51
|
"cost",
|
|
22
52
|
"time",
|
|
23
53
|
];
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_STATUSLINE_CONFIG: StatuslineConfig = {
|
|
56
|
+
palette: "tokyo-night",
|
|
57
|
+
density: "compact",
|
|
58
|
+
separator: "none",
|
|
59
|
+
segments: DEFAULT_SEGMENTS,
|
|
60
|
+
segmentText: {
|
|
61
|
+
brand: { prefix: "", suffix: "" },
|
|
62
|
+
provider: { prefix: "🔌 ", suffix: "" },
|
|
63
|
+
model: { prefix: "🤖 ", suffix: "" },
|
|
64
|
+
thinking: { prefix: "🧠 ", suffix: "" },
|
|
65
|
+
cwd: { prefix: "📁 ", suffix: "" },
|
|
66
|
+
branch: { prefix: "🌿 ", suffix: "" },
|
|
67
|
+
tools: { prefix: "", suffix: "" },
|
|
68
|
+
context: { prefix: "🪟 ctx ", suffix: "" },
|
|
69
|
+
tokens: { prefix: "🔢 ", suffix: "" },
|
|
70
|
+
cost: { prefix: "💸 $", suffix: "" },
|
|
71
|
+
time: { prefix: "🕒 ", suffix: "" },
|
|
72
|
+
turn: { prefix: "🔁 #", suffix: "" },
|
|
73
|
+
},
|
|
74
|
+
extensionStatusIcons: DEFAULT_EXTENSION_STATUS_ICONS,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const DEFAULT_STATUSLINE_DOCUMENT = `${JSON.stringify(DEFAULT_STATUSLINE_CONFIG, null, "\t")}\n`;
|
|
78
|
+
|
|
79
|
+
export interface StatuslineConfigDiagnostic {
|
|
80
|
+
severity: "warning" | "error";
|
|
81
|
+
code: "unknown" | "invalid" | "parse" | "io";
|
|
82
|
+
path: string;
|
|
83
|
+
message: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface LoadedStatuslineSettings {
|
|
87
|
+
config: StatuslineConfig;
|
|
88
|
+
source: "built-in" | "user";
|
|
89
|
+
settingsPath: string;
|
|
90
|
+
rawDocument?: string;
|
|
91
|
+
diagnostics: StatuslineConfigDiagnostic[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface InitialFileSystem {
|
|
95
|
+
mkdirSync: typeof mkdirSync;
|
|
96
|
+
writeFileSync: typeof writeFileSync;
|
|
97
|
+
linkSync: typeof linkSync;
|
|
98
|
+
rmSync: typeof rmSync;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface AtomicFileSystem {
|
|
102
|
+
mkdirSync: typeof mkdirSync;
|
|
103
|
+
writeFileSync: typeof writeFileSync;
|
|
104
|
+
renameSync: typeof renameSync;
|
|
105
|
+
rmSync: typeof rmSync;
|
|
106
|
+
}
|
|
107
|
+
|
|
24
108
|
let pendingSettingsNotice: string | undefined;
|
|
25
109
|
|
|
26
|
-
export
|
|
27
|
-
|
|
110
|
+
export function settingsFilePath(agentDir = getAgentDir()): string {
|
|
111
|
+
return join(agentDir, SETTINGS_FILE_NAME);
|
|
28
112
|
}
|
|
29
113
|
|
|
30
114
|
export function createDefaultConfig(): StatuslineConfig {
|
|
115
|
+
return cloneConfig(DEFAULT_STATUSLINE_CONFIG);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function normalizeStatuslineConfig(value: unknown): {
|
|
119
|
+
config: StatuslineConfig;
|
|
120
|
+
diagnostics: StatuslineConfigDiagnostic[];
|
|
121
|
+
} {
|
|
122
|
+
const config = createDefaultConfig();
|
|
123
|
+
const diagnostics: StatuslineConfigDiagnostic[] = [];
|
|
124
|
+
if (!isRecord(value)) {
|
|
125
|
+
return {
|
|
126
|
+
config,
|
|
127
|
+
diagnostics: [invalidDiagnostic("", "Settings must contain a JSON object", "error")],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const knownRoot = new Set([
|
|
132
|
+
"palette",
|
|
133
|
+
"density",
|
|
134
|
+
"separator",
|
|
135
|
+
"segments",
|
|
136
|
+
"segmentText",
|
|
137
|
+
"extensionStatusIcons",
|
|
138
|
+
]);
|
|
139
|
+
for (const key of Object.keys(value)) {
|
|
140
|
+
if (!knownRoot.has(key)) diagnostics.push(unknownDiagnostic(key));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
normalizeEnum(value, "palette", PALETTE_NAMES, config, diagnostics);
|
|
144
|
+
normalizeEnum(value, "density", DENSITIES, config, diagnostics);
|
|
145
|
+
normalizeEnum(value, "separator", SEPARATOR_NAMES, config, diagnostics);
|
|
146
|
+
|
|
147
|
+
if (value.segments !== undefined) {
|
|
148
|
+
if (!Array.isArray(value.segments)) {
|
|
149
|
+
diagnostics.push(invalidDiagnostic("segments", "Expected an array of segment names"));
|
|
150
|
+
} else {
|
|
151
|
+
const segments: ConfigSegmentName[] = [];
|
|
152
|
+
const seen = new Set<SegmentName>();
|
|
153
|
+
for (const [index, item] of value.segments.entries()) {
|
|
154
|
+
const path = `segments[${index}]`;
|
|
155
|
+
if (typeof item !== "string" || !isConfigSegmentName(item)) {
|
|
156
|
+
diagnostics.push(invalidDiagnostic(path, "Unknown or non-string segment name"));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (item === LINE_BREAK_SEGMENT_NAME) {
|
|
160
|
+
if (segments.at(-1) === LINE_BREAK_SEGMENT_NAME) {
|
|
161
|
+
diagnostics.push(
|
|
162
|
+
invalidDiagnostic(path, "Consecutive line_break segments are not allowed"),
|
|
163
|
+
);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
segments.push(item);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (seen.has(item)) {
|
|
170
|
+
diagnostics.push(invalidDiagnostic(path, `Duplicate segment ${JSON.stringify(item)}`));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
seen.add(item);
|
|
174
|
+
segments.push(item);
|
|
175
|
+
}
|
|
176
|
+
config.segments = segments;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (value.segmentText !== undefined) {
|
|
181
|
+
if (!isRecord(value.segmentText)) {
|
|
182
|
+
diagnostics.push(invalidDiagnostic("segmentText", "Expected an object"));
|
|
183
|
+
} else {
|
|
184
|
+
for (const [name, presentation] of Object.entries(value.segmentText)) {
|
|
185
|
+
const path = `segmentText.${name}`;
|
|
186
|
+
if (!isSegmentName(name)) {
|
|
187
|
+
diagnostics.push(unknownDiagnostic(path));
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (!isRecord(presentation)) {
|
|
191
|
+
diagnostics.push(invalidDiagnostic(path, "Expected an object"));
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
for (const key of Object.keys(presentation)) {
|
|
195
|
+
if (key !== "prefix" && key !== "suffix") {
|
|
196
|
+
diagnostics.push(unknownDiagnostic(`${path}.${key}`));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const field of ["prefix", "suffix"] as const) {
|
|
200
|
+
const fieldValue = presentation[field];
|
|
201
|
+
if (fieldValue === undefined) continue;
|
|
202
|
+
if (typeof fieldValue !== "string") {
|
|
203
|
+
diagnostics.push(invalidDiagnostic(`${path}.${field}`, "Expected a string"));
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (/[\r\n\u2028\u2029]/u.test(fieldValue)) {
|
|
207
|
+
diagnostics.push(
|
|
208
|
+
invalidDiagnostic(`${path}.${field}`, "Line breaks are not allowed; use line_break"),
|
|
209
|
+
);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (hasControlCharacter(fieldValue)) {
|
|
213
|
+
diagnostics.push(
|
|
214
|
+
invalidDiagnostic(`${path}.${field}`, "Control characters are not allowed"),
|
|
215
|
+
);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
config.segmentText[name][field] = fieldValue;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (value.extensionStatusIcons !== undefined) {
|
|
225
|
+
if (!isRecord(value.extensionStatusIcons)) {
|
|
226
|
+
diagnostics.push(invalidDiagnostic("extensionStatusIcons", "Expected an object"));
|
|
227
|
+
} else {
|
|
228
|
+
for (const [key, icon] of Object.entries(value.extensionStatusIcons)) {
|
|
229
|
+
if (typeof icon !== "string") {
|
|
230
|
+
diagnostics.push(invalidDiagnostic(`extensionStatusIcons.${key}`, "Expected a string"));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
Object.defineProperty(config.extensionStatusIcons, key, {
|
|
234
|
+
value: icon,
|
|
235
|
+
enumerable: true,
|
|
236
|
+
configurable: true,
|
|
237
|
+
writable: true,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { config, diagnostics };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function loadStatuslineSettings(settingsPath: string): LoadedStatuslineSettings {
|
|
247
|
+
let rawDocument: string;
|
|
248
|
+
try {
|
|
249
|
+
rawDocument = readFileSync(settingsPath, "utf8");
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (!pathExists(settingsPath)) {
|
|
252
|
+
return builtInSettings(settingsPath);
|
|
253
|
+
}
|
|
254
|
+
return builtInSettings(settingsPath, [
|
|
255
|
+
diagnostic("error", "io", "", `Unable to read settings: ${formatError(error)}`),
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let parsed: unknown;
|
|
260
|
+
try {
|
|
261
|
+
parsed = JSON.parse(rawDocument);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return {
|
|
264
|
+
...builtInSettings(settingsPath, [
|
|
265
|
+
diagnostic("error", "parse", "", `Unable to parse JSON: ${formatError(error)}`),
|
|
266
|
+
]),
|
|
267
|
+
rawDocument,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const normalized = normalizeStatuslineConfig(parsed);
|
|
31
271
|
return {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
segments: [...DEFAULT_SEGMENTS],
|
|
38
|
-
extensionStatusIcons: readStatuslineSettings().extensionStatusIcons,
|
|
272
|
+
config: normalized.config,
|
|
273
|
+
source: normalized.diagnostics.some((item) => item.severity === "error") ? "built-in" : "user",
|
|
274
|
+
settingsPath,
|
|
275
|
+
rawDocument,
|
|
276
|
+
diagnostics: normalized.diagnostics,
|
|
39
277
|
};
|
|
40
278
|
}
|
|
41
279
|
|
|
42
|
-
export function
|
|
43
|
-
|
|
280
|
+
export function loadOrCreateStatuslineSettings(
|
|
281
|
+
agentDir = getAgentDir(),
|
|
282
|
+
overrides: Partial<InitialFileSystem> = {},
|
|
283
|
+
): LoadedStatuslineSettings {
|
|
44
284
|
pendingSettingsNotice = undefined;
|
|
45
|
-
const canonicalPath =
|
|
46
|
-
const legacyPath = join(
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (!canonical.valid) notices.push(`${SETTINGS_FILE} is invalid and was ignored.`);
|
|
51
|
-
if (existsSync(legacyPath)) {
|
|
52
|
-
notices.push(`${LEGACY_SETTINGS_FILE} ignored because ${SETTINGS_FILE} takes precedence.`);
|
|
285
|
+
const canonicalPath = settingsFilePath(agentDir);
|
|
286
|
+
const legacyPath = join(agentDir, LEGACY_SETTINGS_FILE_NAME);
|
|
287
|
+
if (pathExists(canonicalPath)) {
|
|
288
|
+
if (pathExists(legacyPath)) {
|
|
289
|
+
pendingSettingsNotice = `${LEGACY_SETTINGS_FILE_NAME} ignored because ${SETTINGS_FILE_NAME} takes precedence.`;
|
|
53
290
|
}
|
|
54
|
-
|
|
55
|
-
|
|
291
|
+
return loadStatuslineSettings(canonicalPath);
|
|
292
|
+
}
|
|
293
|
+
if (pathExists(legacyPath)) return migrateLegacySettings(canonicalPath, legacyPath);
|
|
294
|
+
return createInitialSettings(canonicalPath, overrides);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function createInitialSettings(
|
|
298
|
+
canonicalPath: string,
|
|
299
|
+
overrides: Partial<InitialFileSystem>,
|
|
300
|
+
): LoadedStatuslineSettings {
|
|
301
|
+
const fs = { mkdirSync, writeFileSync, linkSync, rmSync, ...overrides };
|
|
302
|
+
const temporaryPath = temporarySettingsPath(canonicalPath);
|
|
303
|
+
try {
|
|
304
|
+
fs.mkdirSync(dirname(canonicalPath), { recursive: true });
|
|
305
|
+
fs.writeFileSync(temporaryPath, DEFAULT_STATUSLINE_DOCUMENT, {
|
|
306
|
+
encoding: "utf8",
|
|
307
|
+
flag: "wx",
|
|
308
|
+
});
|
|
309
|
+
try {
|
|
310
|
+
fs.linkSync(temporaryPath, canonicalPath);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (isAlreadyExistsError(error)) return loadStatuslineSettings(canonicalPath);
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
return loadStatuslineSettings(canonicalPath);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
return builtInSettings(canonicalPath, [
|
|
318
|
+
diagnostic("warning", "io", "", `Unable to create default settings: ${formatError(error)}`),
|
|
319
|
+
]);
|
|
320
|
+
} finally {
|
|
321
|
+
removeTemporaryFile(fs.rmSync, temporaryPath);
|
|
56
322
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function migrateLegacySettings(
|
|
326
|
+
canonicalPath: string,
|
|
327
|
+
legacyPath: string,
|
|
328
|
+
): LoadedStatuslineSettings {
|
|
329
|
+
const legacy = loadStatuslineSettings(legacyPath);
|
|
330
|
+
if (
|
|
331
|
+
legacy.source !== "user" ||
|
|
332
|
+
legacy.rawDocument === undefined ||
|
|
333
|
+
blockingDiagnostics(legacy.diagnostics).length > 0
|
|
334
|
+
) {
|
|
335
|
+
pendingSettingsNotice = `${LEGACY_SETTINGS_FILE_NAME} is invalid and was ignored.`;
|
|
336
|
+
return legacy;
|
|
62
337
|
}
|
|
63
|
-
let
|
|
338
|
+
let identity: FileIdentity;
|
|
64
339
|
try {
|
|
65
|
-
|
|
340
|
+
identity = installFileExclusively(canonicalPath, legacy.rawDocument);
|
|
66
341
|
} catch (error) {
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
...(!canonical.valid ? [`${SETTINGS_FILE} is invalid and was ignored.`] : []),
|
|
71
|
-
`${LEGACY_SETTINGS_FILE} ignored because ${SETTINGS_FILE} was created concurrently.`,
|
|
72
|
-
].join("\n");
|
|
73
|
-
return canonical.settings;
|
|
342
|
+
if (pathExists(canonicalPath)) {
|
|
343
|
+
pendingSettingsNotice = `${LEGACY_SETTINGS_FILE_NAME} ignored because ${SETTINGS_FILE_NAME} was created concurrently.`;
|
|
344
|
+
return loadStatuslineSettings(canonicalPath);
|
|
74
345
|
}
|
|
75
346
|
pendingSettingsNotice = `Statusline settings migration failed: ${formatError(error)}. The legacy file was used for this session.`;
|
|
76
|
-
return legacy
|
|
77
|
-
}
|
|
78
|
-
if (!fileContentsEqual(legacyPath, legacy.
|
|
79
|
-
pendingSettingsNotice = removeFileIfIdentityMatches(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
? `${LEGACY_SETTINGS_FILE} changed during migration; the stale ${SETTINGS_FILE} snapshot was removed.`
|
|
85
|
-
: `${LEGACY_SETTINGS_FILE} changed during migration, but ${SETTINGS_FILE} was replaced concurrently and takes precedence on the next load.`;
|
|
86
|
-
return legacy.settings;
|
|
347
|
+
return legacy;
|
|
348
|
+
}
|
|
349
|
+
if (!fileContentsEqual(legacyPath, legacy.rawDocument)) {
|
|
350
|
+
pendingSettingsNotice = removeFileIfIdentityMatches(canonicalPath, identity, legacy.rawDocument)
|
|
351
|
+
? `${LEGACY_SETTINGS_FILE_NAME} changed during migration; the stale ${SETTINGS_FILE_NAME} snapshot was removed.`
|
|
352
|
+
: `${LEGACY_SETTINGS_FILE_NAME} changed during migration, but ${SETTINGS_FILE_NAME} was replaced concurrently and takes precedence on the next load.`;
|
|
353
|
+
return legacy;
|
|
87
354
|
}
|
|
88
355
|
try {
|
|
89
356
|
rmSync(legacyPath);
|
|
90
|
-
pendingSettingsNotice = `Statusline settings migrated from ${
|
|
357
|
+
pendingSettingsNotice = `Statusline settings migrated from ${LEGACY_SETTINGS_FILE_NAME} to ${SETTINGS_FILE_NAME}.`;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
pendingSettingsNotice = `Statusline settings migrated to ${SETTINGS_FILE_NAME}, but ${LEGACY_SETTINGS_FILE_NAME} could not be removed: ${formatError(error)}.`;
|
|
360
|
+
}
|
|
361
|
+
return loadStatuslineSettings(canonicalPath);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function saveStatuslineSettingsDocument(
|
|
365
|
+
settingsPath: string,
|
|
366
|
+
rawDocument: string,
|
|
367
|
+
overrides: Partial<AtomicFileSystem> = {},
|
|
368
|
+
): LoadedStatuslineSettings {
|
|
369
|
+
let parsed: unknown;
|
|
370
|
+
try {
|
|
371
|
+
parsed = JSON.parse(rawDocument);
|
|
91
372
|
} catch (error) {
|
|
92
|
-
|
|
373
|
+
throw new Error(`Unable to parse JSON: ${formatError(error)}`);
|
|
374
|
+
}
|
|
375
|
+
const normalized = normalizeStatuslineConfig(parsed);
|
|
376
|
+
const blocking = blockingDiagnostics(normalized.diagnostics);
|
|
377
|
+
if (blocking.length > 0) {
|
|
378
|
+
throw new Error(blocking.map((item) => `${item.path || "root"}: ${item.message}`).join("\n"));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const fs = { mkdirSync, writeFileSync, renameSync, rmSync, ...overrides };
|
|
382
|
+
const temporaryPath = temporarySettingsPath(settingsPath);
|
|
383
|
+
try {
|
|
384
|
+
fs.mkdirSync(dirname(settingsPath), { recursive: true });
|
|
385
|
+
fs.writeFileSync(temporaryPath, rawDocument, { encoding: "utf8", flag: "wx" });
|
|
386
|
+
fs.renameSync(temporaryPath, settingsPath);
|
|
387
|
+
} finally {
|
|
388
|
+
removeTemporaryFile(fs.rmSync, temporaryPath);
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
config: normalized.config,
|
|
392
|
+
source: "user",
|
|
393
|
+
settingsPath,
|
|
394
|
+
rawDocument,
|
|
395
|
+
diagnostics: normalized.diagnostics,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function consumeStatuslineSettingsNotice(): string | undefined {
|
|
400
|
+
const notice = pendingSettingsNotice;
|
|
401
|
+
pendingSettingsNotice = undefined;
|
|
402
|
+
return notice;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function readStatuslineSettings(settingsPath?: string): StatuslineConfig {
|
|
406
|
+
return settingsPath
|
|
407
|
+
? loadStatuslineSettings(settingsPath).config
|
|
408
|
+
: loadOrCreateStatuslineSettings().config;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function normalizeStatuslineSettings(value: unknown): StatuslineConfig {
|
|
412
|
+
return normalizeStatuslineConfig(value).config;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function normalizeEnum<
|
|
416
|
+
K extends "palette" | "density" | "separator",
|
|
417
|
+
T extends StatuslineConfig[K],
|
|
418
|
+
>(
|
|
419
|
+
value: Record<string, unknown>,
|
|
420
|
+
field: K,
|
|
421
|
+
accepted: readonly T[],
|
|
422
|
+
config: StatuslineConfig,
|
|
423
|
+
diagnostics: StatuslineConfigDiagnostic[],
|
|
424
|
+
) {
|
|
425
|
+
const candidate = value[field];
|
|
426
|
+
if (candidate === undefined) return;
|
|
427
|
+
if (typeof candidate !== "string" || !accepted.includes(candidate as T)) {
|
|
428
|
+
diagnostics.push(
|
|
429
|
+
invalidDiagnostic(field, `Expected one of: ${accepted.map(String).join(", ")}`),
|
|
430
|
+
);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
config[field] = candidate as StatuslineConfig[K];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function cloneConfig(config: StatuslineConfig): StatuslineConfig {
|
|
437
|
+
return {
|
|
438
|
+
...config,
|
|
439
|
+
segments: [...config.segments],
|
|
440
|
+
segmentText: Object.fromEntries(
|
|
441
|
+
SEGMENT_NAMES.map((name) => [name, { ...config.segmentText[name] }]),
|
|
442
|
+
) as StatuslineConfig["segmentText"],
|
|
443
|
+
extensionStatusIcons: { ...config.extensionStatusIcons },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function builtInSettings(
|
|
448
|
+
settingsPath: string,
|
|
449
|
+
diagnostics: StatuslineConfigDiagnostic[] = [],
|
|
450
|
+
): LoadedStatuslineSettings {
|
|
451
|
+
return {
|
|
452
|
+
config: createDefaultConfig(),
|
|
453
|
+
source: "built-in",
|
|
454
|
+
settingsPath,
|
|
455
|
+
diagnostics,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function hasControlCharacter(value: string): boolean {
|
|
460
|
+
for (const character of value) {
|
|
461
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
462
|
+
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) return true;
|
|
463
|
+
}
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
468
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function isConfigSegmentName(value: string): value is ConfigSegmentName {
|
|
472
|
+
return value === LINE_BREAK_SEGMENT_NAME || isSegmentName(value);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function isSegmentName(value: string): value is SegmentName {
|
|
476
|
+
return (SEGMENT_NAMES as readonly string[]).includes(value);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function blockingDiagnostics(
|
|
480
|
+
diagnostics: readonly StatuslineConfigDiagnostic[],
|
|
481
|
+
): StatuslineConfigDiagnostic[] {
|
|
482
|
+
return diagnostics.filter((item) => item.code !== "unknown");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function unknownDiagnostic(path: string): StatuslineConfigDiagnostic {
|
|
486
|
+
return diagnostic("warning", "unknown", path, `Unknown setting ${JSON.stringify(path)}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function invalidDiagnostic(
|
|
490
|
+
path: string,
|
|
491
|
+
message: string,
|
|
492
|
+
severity: StatuslineConfigDiagnostic["severity"] = "warning",
|
|
493
|
+
): StatuslineConfigDiagnostic {
|
|
494
|
+
return diagnostic(severity, "invalid", path, message);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function diagnostic(
|
|
498
|
+
severity: StatuslineConfigDiagnostic["severity"],
|
|
499
|
+
code: StatuslineConfigDiagnostic["code"],
|
|
500
|
+
path: string,
|
|
501
|
+
message: string,
|
|
502
|
+
): StatuslineConfigDiagnostic {
|
|
503
|
+
return { severity, code, path, message };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function temporarySettingsPath(settingsPath: string): string {
|
|
507
|
+
return join(dirname(settingsPath), `.${SETTINGS_FILE_NAME}.${randomUUID()}.tmp`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function removeTemporaryFile(remove: typeof rmSync, temporaryPath: string) {
|
|
511
|
+
try {
|
|
512
|
+
remove(temporaryPath, { force: true });
|
|
513
|
+
} catch {
|
|
514
|
+
// Best-effort cleanup must not replace the original operation result.
|
|
93
515
|
}
|
|
94
|
-
return legacy.settings;
|
|
95
516
|
}
|
|
96
517
|
|
|
97
518
|
type FileIdentity = { dev: number; ino: number };
|
|
98
519
|
|
|
99
520
|
function installFileExclusively(filePath: string, contents: string): FileIdentity {
|
|
100
|
-
|
|
521
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
522
|
+
const temporaryPath = temporarySettingsPath(filePath);
|
|
101
523
|
try {
|
|
102
|
-
writeFileSync(
|
|
103
|
-
const identity = lstatSync(
|
|
104
|
-
linkSync(
|
|
524
|
+
writeFileSync(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
|
|
525
|
+
const identity = lstatSync(temporaryPath);
|
|
526
|
+
linkSync(temporaryPath, filePath);
|
|
105
527
|
return { dev: identity.dev, ino: identity.ino };
|
|
106
528
|
} finally {
|
|
107
|
-
|
|
108
|
-
rmSync(tempFile, { force: true });
|
|
109
|
-
} catch {
|
|
110
|
-
// Preserve the migration result if best-effort temp cleanup fails.
|
|
111
|
-
}
|
|
529
|
+
removeTemporaryFile(rmSync, temporaryPath);
|
|
112
530
|
}
|
|
113
531
|
}
|
|
114
532
|
|
|
@@ -116,7 +534,7 @@ function removeFileIfIdentityMatches(
|
|
|
116
534
|
filePath: string,
|
|
117
535
|
expected: FileIdentity,
|
|
118
536
|
expectedContents: string,
|
|
119
|
-
) {
|
|
537
|
+
): boolean {
|
|
120
538
|
try {
|
|
121
539
|
const current = lstatSync(filePath);
|
|
122
540
|
if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
|
|
@@ -128,7 +546,7 @@ function removeFileIfIdentityMatches(
|
|
|
128
546
|
}
|
|
129
547
|
}
|
|
130
548
|
|
|
131
|
-
function fileContentsEqual(path: string, expected: string) {
|
|
549
|
+
function fileContentsEqual(path: string, expected: string): boolean {
|
|
132
550
|
try {
|
|
133
551
|
return readFileSync(path, "utf8") === expected;
|
|
134
552
|
} catch {
|
|
@@ -136,55 +554,19 @@ function fileContentsEqual(path: string, expected: string) {
|
|
|
136
554
|
}
|
|
137
555
|
}
|
|
138
556
|
|
|
139
|
-
|
|
140
|
-
const notice = pendingSettingsNotice;
|
|
141
|
-
pendingSettingsNotice = undefined;
|
|
142
|
-
return notice;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function readSettingsFile(settingsPath: string): StatuslineSettings {
|
|
146
|
-
return readSettingsFileResult(settingsPath).settings;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function readSettingsFileResult(settingsPath: string): {
|
|
150
|
-
settings: StatuslineSettings;
|
|
151
|
-
valid: boolean;
|
|
152
|
-
contents?: string;
|
|
153
|
-
} {
|
|
154
|
-
let contents: string;
|
|
557
|
+
function pathExists(path: string): boolean {
|
|
155
558
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
159
|
-
return { settings: { extensionStatusIcons: {} }, valid: false, contents };
|
|
160
|
-
}
|
|
161
|
-
return { settings: normalizeStatuslineSettings(parsed), valid: true, contents };
|
|
559
|
+
lstatSync(path);
|
|
560
|
+
return true;
|
|
162
561
|
} catch {
|
|
163
|
-
return
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function normalizeStatuslineSettings(value: unknown): StatuslineSettings {
|
|
168
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
169
|
-
return { extensionStatusIcons: {} };
|
|
170
|
-
const icons = (value as { extensionStatusIcons?: unknown }).extensionStatusIcons;
|
|
171
|
-
if (!icons || typeof icons !== "object" || Array.isArray(icons)) {
|
|
172
|
-
return { extensionStatusIcons: {} };
|
|
562
|
+
return false;
|
|
173
563
|
}
|
|
174
|
-
return {
|
|
175
|
-
extensionStatusIcons: Object.fromEntries(
|
|
176
|
-
Object.entries(icons).filter(
|
|
177
|
-
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
178
|
-
),
|
|
179
|
-
),
|
|
180
|
-
};
|
|
181
564
|
}
|
|
182
565
|
|
|
183
|
-
function
|
|
184
|
-
|
|
185
|
-
return value === "classic" || value === "tokyo-night" ? value : DEFAULT_PRESET;
|
|
566
|
+
function isAlreadyExistsError(error: unknown): boolean {
|
|
567
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
186
568
|
}
|
|
187
569
|
|
|
188
|
-
function formatError(error: unknown) {
|
|
570
|
+
function formatError(error: unknown): string {
|
|
189
571
|
return error instanceof Error ? error.message : String(error);
|
|
190
572
|
}
|