@narumitw/pi-statusline 0.20.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 -95
- 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 +104 -64
- package/src/settings.ts +492 -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 -38
- /package/{presets → src}/ansi.ts +0 -0
package/src/settings.ts
CHANGED
|
@@ -1,15 +1,46 @@
|
|
|
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",
|
|
43
|
+
"provider",
|
|
13
44
|
"model",
|
|
14
45
|
"thinking",
|
|
15
46
|
"cwd",
|
|
@@ -20,94 +51,482 @@ const DEFAULT_SEGMENTS: SegmentName[] = [
|
|
|
20
51
|
"cost",
|
|
21
52
|
"time",
|
|
22
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
|
+
|
|
23
108
|
let pendingSettingsNotice: string | undefined;
|
|
24
109
|
|
|
25
|
-
export
|
|
26
|
-
|
|
110
|
+
export function settingsFilePath(agentDir = getAgentDir()): string {
|
|
111
|
+
return join(agentDir, SETTINGS_FILE_NAME);
|
|
27
112
|
}
|
|
28
113
|
|
|
29
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);
|
|
30
271
|
return {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
segments: [...DEFAULT_SEGMENTS],
|
|
37
|
-
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,
|
|
38
277
|
};
|
|
39
278
|
}
|
|
40
279
|
|
|
41
|
-
export function
|
|
42
|
-
|
|
280
|
+
export function loadOrCreateStatuslineSettings(
|
|
281
|
+
agentDir = getAgentDir(),
|
|
282
|
+
overrides: Partial<InitialFileSystem> = {},
|
|
283
|
+
): LoadedStatuslineSettings {
|
|
43
284
|
pendingSettingsNotice = undefined;
|
|
44
|
-
const canonicalPath =
|
|
45
|
-
const legacyPath = join(
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (!canonical.valid) notices.push(`${SETTINGS_FILE} is invalid and was ignored.`);
|
|
50
|
-
if (existsSync(legacyPath)) {
|
|
51
|
-
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.`;
|
|
52
290
|
}
|
|
53
|
-
|
|
54
|
-
|
|
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);
|
|
55
322
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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;
|
|
61
337
|
}
|
|
62
|
-
let
|
|
338
|
+
let identity: FileIdentity;
|
|
63
339
|
try {
|
|
64
|
-
|
|
340
|
+
identity = installFileExclusively(canonicalPath, legacy.rawDocument);
|
|
65
341
|
} catch (error) {
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
...(!canonical.valid ? [`${SETTINGS_FILE} is invalid and was ignored.`] : []),
|
|
70
|
-
`${LEGACY_SETTINGS_FILE} ignored because ${SETTINGS_FILE} was created concurrently.`,
|
|
71
|
-
].join("\n");
|
|
72
|
-
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);
|
|
73
345
|
}
|
|
74
346
|
pendingSettingsNotice = `Statusline settings migration failed: ${formatError(error)}. The legacy file was used for this session.`;
|
|
75
|
-
return legacy
|
|
76
|
-
}
|
|
77
|
-
if (!fileContentsEqual(legacyPath, legacy.
|
|
78
|
-
pendingSettingsNotice = removeFileIfIdentityMatches(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
? `${LEGACY_SETTINGS_FILE} changed during migration; the stale ${SETTINGS_FILE} snapshot was removed.`
|
|
84
|
-
: `${LEGACY_SETTINGS_FILE} changed during migration, but ${SETTINGS_FILE} was replaced concurrently and takes precedence on the next load.`;
|
|
85
|
-
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;
|
|
86
354
|
}
|
|
87
355
|
try {
|
|
88
356
|
rmSync(legacyPath);
|
|
89
|
-
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);
|
|
90
372
|
} catch (error) {
|
|
91
|
-
|
|
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.
|
|
92
515
|
}
|
|
93
|
-
return legacy.settings;
|
|
94
516
|
}
|
|
95
517
|
|
|
96
518
|
type FileIdentity = { dev: number; ino: number };
|
|
97
519
|
|
|
98
520
|
function installFileExclusively(filePath: string, contents: string): FileIdentity {
|
|
99
|
-
|
|
521
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
522
|
+
const temporaryPath = temporarySettingsPath(filePath);
|
|
100
523
|
try {
|
|
101
|
-
writeFileSync(
|
|
102
|
-
const identity = lstatSync(
|
|
103
|
-
linkSync(
|
|
524
|
+
writeFileSync(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
|
|
525
|
+
const identity = lstatSync(temporaryPath);
|
|
526
|
+
linkSync(temporaryPath, filePath);
|
|
104
527
|
return { dev: identity.dev, ino: identity.ino };
|
|
105
528
|
} finally {
|
|
106
|
-
|
|
107
|
-
rmSync(tempFile, { force: true });
|
|
108
|
-
} catch {
|
|
109
|
-
// Preserve the migration result if best-effort temp cleanup fails.
|
|
110
|
-
}
|
|
529
|
+
removeTemporaryFile(rmSync, temporaryPath);
|
|
111
530
|
}
|
|
112
531
|
}
|
|
113
532
|
|
|
@@ -115,7 +534,7 @@ function removeFileIfIdentityMatches(
|
|
|
115
534
|
filePath: string,
|
|
116
535
|
expected: FileIdentity,
|
|
117
536
|
expectedContents: string,
|
|
118
|
-
) {
|
|
537
|
+
): boolean {
|
|
119
538
|
try {
|
|
120
539
|
const current = lstatSync(filePath);
|
|
121
540
|
if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
|
|
@@ -127,7 +546,7 @@ function removeFileIfIdentityMatches(
|
|
|
127
546
|
}
|
|
128
547
|
}
|
|
129
548
|
|
|
130
|
-
function fileContentsEqual(path: string, expected: string) {
|
|
549
|
+
function fileContentsEqual(path: string, expected: string): boolean {
|
|
131
550
|
try {
|
|
132
551
|
return readFileSync(path, "utf8") === expected;
|
|
133
552
|
} catch {
|
|
@@ -135,55 +554,19 @@ function fileContentsEqual(path: string, expected: string) {
|
|
|
135
554
|
}
|
|
136
555
|
}
|
|
137
556
|
|
|
138
|
-
|
|
139
|
-
const notice = pendingSettingsNotice;
|
|
140
|
-
pendingSettingsNotice = undefined;
|
|
141
|
-
return notice;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function readSettingsFile(settingsPath: string): StatuslineSettings {
|
|
145
|
-
return readSettingsFileResult(settingsPath).settings;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function readSettingsFileResult(settingsPath: string): {
|
|
149
|
-
settings: StatuslineSettings;
|
|
150
|
-
valid: boolean;
|
|
151
|
-
contents?: string;
|
|
152
|
-
} {
|
|
153
|
-
let contents: string;
|
|
557
|
+
function pathExists(path: string): boolean {
|
|
154
558
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
158
|
-
return { settings: { extensionStatusIcons: {} }, valid: false, contents };
|
|
159
|
-
}
|
|
160
|
-
return { settings: normalizeStatuslineSettings(parsed), valid: true, contents };
|
|
559
|
+
lstatSync(path);
|
|
560
|
+
return true;
|
|
161
561
|
} catch {
|
|
162
|
-
return
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function normalizeStatuslineSettings(value: unknown): StatuslineSettings {
|
|
167
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
168
|
-
return { extensionStatusIcons: {} };
|
|
169
|
-
const icons = (value as { extensionStatusIcons?: unknown }).extensionStatusIcons;
|
|
170
|
-
if (!icons || typeof icons !== "object" || Array.isArray(icons)) {
|
|
171
|
-
return { extensionStatusIcons: {} };
|
|
562
|
+
return false;
|
|
172
563
|
}
|
|
173
|
-
return {
|
|
174
|
-
extensionStatusIcons: Object.fromEntries(
|
|
175
|
-
Object.entries(icons).filter(
|
|
176
|
-
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
177
|
-
),
|
|
178
|
-
),
|
|
179
|
-
};
|
|
180
564
|
}
|
|
181
565
|
|
|
182
|
-
function
|
|
183
|
-
|
|
184
|
-
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";
|
|
185
568
|
}
|
|
186
569
|
|
|
187
|
-
function formatError(error: unknown) {
|
|
570
|
+
function formatError(error: unknown): string {
|
|
188
571
|
return error instanceof Error ? error.message : String(error);
|
|
189
572
|
}
|