oxlint-tui 1.0.19 → 1.1.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 +20 -22
- package/dist/index.js +340 -0
- package/dist/rendering.js +153 -0
- package/dist/types.js +1 -0
- package/package.json +31 -16
- package/tui.js +0 -640
package/README.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
A lightweight, dependency-free Node.js Terminal User Interface (TUI) for browsing, toggling and visualizing [oxlint](https://github.com/oxc-project/oxc) rules and the number of warnings/errors they produce when toggled ON.
|
|
4
4
|
|
|
5
|
-

|
|
6
|
-
|
|
7
5
|
It automatically loads your local configuration to show you the status of the rules toggled in your project and allows you to toggle them by selecting a rule in the Rules pane and pressing <kbd>1</kbd>, <kbd>2</kbd>, or <kbd>3</kbd>. The config file is modified in real-time.
|
|
8
6
|
|
|
9
7
|
**NOTE**: At the moment, comments will be erased from your configuration file when adding or toggling rules.
|
|
@@ -16,10 +14,10 @@ Configuring linters often involves jumping between your editor, a massive JSON f
|
|
|
16
14
|
|
|
17
15
|
## Features
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
- **Config Aware**: provides information about the rules used in your project by loading `.oxlintrc.json`.
|
|
18
|
+
- **Details**: View category, scope, fix, default and type-aware rule parameters at a glance.
|
|
19
|
+
- **View Docs**: Press <kbd>ENTER</kbd> on any rule to open its official documentation in your browser.
|
|
20
|
+
- **Zero Dependencies**: Written in pure Node.js without any heavy TUI libraries.
|
|
23
21
|
|
|
24
22
|
## Usage
|
|
25
23
|
|
|
@@ -51,28 +49,28 @@ oxlint-tui
|
|
|
51
49
|
|
|
52
50
|
## Keyboard Controls
|
|
53
51
|
|
|
54
|
-
| Key
|
|
55
|
-
|
|
|
56
|
-
| **↑** / **k**
|
|
57
|
-
| **↓** / **j**
|
|
58
|
-
| **←** / **h**
|
|
59
|
-
| **→** / **l**
|
|
60
|
-
| **1**
|
|
61
|
-
| **2**
|
|
62
|
-
| **3**
|
|
63
|
-
| **x**
|
|
64
|
-
| **r**
|
|
65
|
-
| **Enter**
|
|
66
|
-
| **q** / **Esc** | Quit
|
|
52
|
+
| Key | Action |
|
|
53
|
+
| :-------------- | :-------------------------------------- | --- |
|
|
54
|
+
| **↑** / **k** | Move selection Up |
|
|
55
|
+
| **↓** / **j** | Move selection Down |
|
|
56
|
+
| **←** / **h** | Move focus Left (Categories <-> Rules) |
|
|
57
|
+
| **→** / **l** | Move focus Right (Categories <-> Rules) | |
|
|
58
|
+
| **1** | Set selected rule to "off" |
|
|
59
|
+
| **2** | Set selected rule to "warn" |
|
|
60
|
+
| **3** | Set selected rule to "error" |
|
|
61
|
+
| **x** | Run "oxlint" with the selected rule |
|
|
62
|
+
| **r** | Run "oxlint with all enabled rules |
|
|
63
|
+
| **Enter** | Open Rule Documentation in Browser |
|
|
64
|
+
| **q** / **Esc** | Quit |
|
|
67
65
|
|
|
68
66
|
## Requirements
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
- Node.js >= 16
|
|
69
|
+
- `oxlint` (The tool runs `npx oxlint --rules --format=json` internally to fetch definitions)
|
|
72
70
|
|
|
73
71
|
## Roadmap
|
|
74
72
|
|
|
75
|
-
The goal is to build this into a tool that not only reads the information provided by the oxlint CLI and your configuration file -
|
|
73
|
+
The goal is to build this into a tool that not only reads the information provided by the oxlint CLI and your configuration file - but also allows to fully customize the configuration. Oxlint provides a lot more flexibility than just toggling rules on/off, so making this fully functional is going to require more work.
|
|
76
74
|
|
|
77
75
|
If you're willing and able, please feel free to [contribute to this project](https://github.com/holoflash/oxlint-tui/blob/main/CONTRIBUTING.md).
|
|
78
76
|
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import readline from "readline";
|
|
4
|
+
import { execSync, exec, spawn } from "node:child_process";
|
|
5
|
+
import { stdout, stdin, exit, platform, argv } from "node:process";
|
|
6
|
+
import {} from "./types.js";
|
|
7
|
+
import { render } from "./rendering.js";
|
|
8
|
+
const OXLINT_VERSION = "1.41.0";
|
|
9
|
+
const TSGOLINT_VERSION = "0.11.1";
|
|
10
|
+
const KEY_MAP = {
|
|
11
|
+
k: { type: "MOVE_UP" },
|
|
12
|
+
up: { type: "MOVE_UP" },
|
|
13
|
+
down: { type: "MOVE_DOWN" },
|
|
14
|
+
j: { type: "MOVE_DOWN" },
|
|
15
|
+
left: { type: "MOVE_LEFT" },
|
|
16
|
+
h: { type: "MOVE_LEFT" },
|
|
17
|
+
right: { type: "MOVE_RIGHT" },
|
|
18
|
+
l: { type: "MOVE_RIGHT" },
|
|
19
|
+
return: { type: "OPEN_DOCS" },
|
|
20
|
+
enter: { type: "OPEN_DOCS" },
|
|
21
|
+
"1": { type: "SET_STATUS", value: "off" },
|
|
22
|
+
"2": { type: "SET_STATUS", value: "warn" },
|
|
23
|
+
"3": { type: "SET_STATUS", value: "error" },
|
|
24
|
+
q: { type: "EXIT" },
|
|
25
|
+
r: { type: "RUN_LINT" },
|
|
26
|
+
x: { type: "RUN_SINGLE_RULE" },
|
|
27
|
+
};
|
|
28
|
+
let state = {
|
|
29
|
+
activePane: 0,
|
|
30
|
+
selectedCategoryIndex: 0,
|
|
31
|
+
selectedRuleIndex: 0,
|
|
32
|
+
categoryScroll: 0,
|
|
33
|
+
ruleScroll: 0,
|
|
34
|
+
isLintInProgress: false,
|
|
35
|
+
message: "oxlint-tui",
|
|
36
|
+
messageType: "dim",
|
|
37
|
+
...loadRules(),
|
|
38
|
+
};
|
|
39
|
+
function updateConfig(rule, newStatus) {
|
|
40
|
+
if (!state.configPath || !state.config)
|
|
41
|
+
return;
|
|
42
|
+
try {
|
|
43
|
+
if (!state.config.rules)
|
|
44
|
+
state.config.rules = {};
|
|
45
|
+
const ruleName = rule.value;
|
|
46
|
+
const canonicalKey = rule.scope === "oxc2" || rule.scope === "eslint" ? ruleName : `${rule.scope}/${ruleName}`;
|
|
47
|
+
const rules = state.config.rules;
|
|
48
|
+
const existingKey = Object.keys(rules).find((key) => key === canonicalKey || key === ruleName || key.endsWith(`/${ruleName}`));
|
|
49
|
+
const targetKey = existingKey || canonicalKey;
|
|
50
|
+
rules[targetKey] = newStatus;
|
|
51
|
+
fs.writeFileSync(state.configPath, JSON.stringify(state.config, null, 2), "utf8");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
state.message = "Failed to write config file";
|
|
55
|
+
state.messageType = "error";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function runLint({ rule = null } = {}) {
|
|
59
|
+
if (state.isLintInProgress)
|
|
60
|
+
return;
|
|
61
|
+
state.isLintInProgress = true;
|
|
62
|
+
let ruleName = rule ? `${rule.scope}/${rule.value}` : null;
|
|
63
|
+
const typeAware = rule
|
|
64
|
+
? rule.type_aware
|
|
65
|
+
: Object.values(state.rulesByCategory)
|
|
66
|
+
.flat()
|
|
67
|
+
.some((ruleItem) => ruleItem.isActive && ruleItem.type_aware === true);
|
|
68
|
+
state.message = "Linting";
|
|
69
|
+
if (ruleName)
|
|
70
|
+
state.message += ` [${ruleName}]`;
|
|
71
|
+
if (typeAware)
|
|
72
|
+
state.message += " with --type-aware";
|
|
73
|
+
state.message += "...";
|
|
74
|
+
state.messageType = "info";
|
|
75
|
+
render(state);
|
|
76
|
+
const npxCmd = platform === "win32" ? "npx.cmd" : "npx";
|
|
77
|
+
const args = ["-q", "--yes", "--package", `oxlint@${OXLINT_VERSION}`];
|
|
78
|
+
if (typeAware) {
|
|
79
|
+
args.push("--package", `oxlint-tsgolint@${TSGOLINT_VERSION}`);
|
|
80
|
+
}
|
|
81
|
+
args.push("--", "oxlint");
|
|
82
|
+
if (typeAware) {
|
|
83
|
+
args.push("--type-aware");
|
|
84
|
+
}
|
|
85
|
+
if (ruleName) {
|
|
86
|
+
args.push("-A", "all", "-D", ruleName);
|
|
87
|
+
}
|
|
88
|
+
const child = spawn(npxCmd, args);
|
|
89
|
+
let stdoutData = "";
|
|
90
|
+
let stderrData = "";
|
|
91
|
+
child.stdout.on("data", (data) => {
|
|
92
|
+
stdoutData += data;
|
|
93
|
+
});
|
|
94
|
+
child.stderr.on("data", (data) => {
|
|
95
|
+
stderrData += data;
|
|
96
|
+
});
|
|
97
|
+
child.on("close", (code) => {
|
|
98
|
+
state.isLintInProgress = false;
|
|
99
|
+
const fullOutput = stdoutData + stderrData;
|
|
100
|
+
const summaryMatch = fullOutput.match(/Found (\d+) warnings? and (\d+) errors?/i);
|
|
101
|
+
if (summaryMatch) {
|
|
102
|
+
const errors = parseInt(summaryMatch[2]);
|
|
103
|
+
state.message = ruleName
|
|
104
|
+
? `[${ruleName}] Found ${errors} issue${errors === 1 ? "" : "s"}`
|
|
105
|
+
: summaryMatch[0];
|
|
106
|
+
state.messageType = errors > 0 ? "error" : "warn";
|
|
107
|
+
}
|
|
108
|
+
else if (stdoutData.toLowerCase().includes("finished") ||
|
|
109
|
+
(code === 0 && stdoutData.length < 200)) {
|
|
110
|
+
state.message = "Linting passed! 0 issues found.";
|
|
111
|
+
if (ruleName)
|
|
112
|
+
state.message = `[${ruleName}] ${state.message}`;
|
|
113
|
+
state.messageType = "success";
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const cleanError = stderrData
|
|
117
|
+
.split("\n")
|
|
118
|
+
.filter((l) => !l.includes("experimental") && !l.includes("Breaking changes") && l.trim() !== "")
|
|
119
|
+
.join(" ");
|
|
120
|
+
state.message = cleanError ? `Error: ${cleanError.substring(0, 50)}...` : "Lint failed";
|
|
121
|
+
state.messageType = "error";
|
|
122
|
+
}
|
|
123
|
+
render(state);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function execute(action) {
|
|
127
|
+
if (!action)
|
|
128
|
+
return;
|
|
129
|
+
const { categories, rulesByCategory, selectedCategoryIndex, selectedRuleIndex, activePane } = state;
|
|
130
|
+
const currentCategory = categories[selectedCategoryIndex];
|
|
131
|
+
const currentCategoryRules = rulesByCategory[currentCategory] || [];
|
|
132
|
+
const viewportHeight = stdout.rows - 8;
|
|
133
|
+
const statsBoxHeight = 7;
|
|
134
|
+
const categoryListHeight = viewportHeight - statsBoxHeight;
|
|
135
|
+
switch (action.type) {
|
|
136
|
+
case "EXIT":
|
|
137
|
+
exitAltScreen();
|
|
138
|
+
exit(0);
|
|
139
|
+
return;
|
|
140
|
+
case "RUN_LINT":
|
|
141
|
+
runLint();
|
|
142
|
+
return;
|
|
143
|
+
case "RUN_SINGLE_RULE": {
|
|
144
|
+
const rule = currentCategoryRules[selectedRuleIndex];
|
|
145
|
+
if (rule)
|
|
146
|
+
runLint({ rule });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
case "OPEN_DOCS": {
|
|
150
|
+
if (activePane === 1) {
|
|
151
|
+
const rule = currentCategoryRules[selectedRuleIndex];
|
|
152
|
+
if (rule)
|
|
153
|
+
openUrl(rule.docs_url || rule.url);
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
case "SET_STATUS": {
|
|
158
|
+
if (activePane !== 1 || !action.value)
|
|
159
|
+
return;
|
|
160
|
+
const rule = currentCategoryRules[selectedRuleIndex];
|
|
161
|
+
if (!rule)
|
|
162
|
+
return;
|
|
163
|
+
updateConfig(rule, action.value);
|
|
164
|
+
const updatedRules = [...currentCategoryRules];
|
|
165
|
+
updatedRules[selectedRuleIndex] = {
|
|
166
|
+
...rule,
|
|
167
|
+
configStatus: action.value,
|
|
168
|
+
isActive: action.value === "error" || action.value === "warn",
|
|
169
|
+
};
|
|
170
|
+
state = {
|
|
171
|
+
...state,
|
|
172
|
+
message: `Rule '${rule.value}' set to: ${action.value}`,
|
|
173
|
+
messageType: "info",
|
|
174
|
+
rulesByCategory: {
|
|
175
|
+
...rulesByCategory,
|
|
176
|
+
[currentCategory]: updatedRules,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
render(state);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
case "MOVE_RIGHT":
|
|
183
|
+
if (activePane !== 1) {
|
|
184
|
+
state = { ...state, activePane: activePane + 1 };
|
|
185
|
+
render(state);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
case "MOVE_LEFT":
|
|
189
|
+
if (activePane !== 0) {
|
|
190
|
+
state = { ...state, activePane: activePane - 1 };
|
|
191
|
+
render(state);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
case "MOVE_UP":
|
|
195
|
+
if (activePane === 0) {
|
|
196
|
+
const nextIndex = selectedCategoryIndex === 0 ? categories.length - 1 : selectedCategoryIndex - 1;
|
|
197
|
+
state = {
|
|
198
|
+
...state,
|
|
199
|
+
selectedCategoryIndex: nextIndex,
|
|
200
|
+
selectedRuleIndex: 0,
|
|
201
|
+
ruleScroll: 0,
|
|
202
|
+
categoryScroll: updateScroll(nextIndex, state.categoryScroll, categoryListHeight),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
else if (activePane === 1) {
|
|
206
|
+
const nextIndex = selectedRuleIndex === 0 ? currentCategoryRules.length - 1 : selectedRuleIndex - 1;
|
|
207
|
+
state = {
|
|
208
|
+
...state,
|
|
209
|
+
selectedRuleIndex: nextIndex,
|
|
210
|
+
ruleScroll: updateScroll(nextIndex, state.ruleScroll, viewportHeight),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
render(state);
|
|
214
|
+
return;
|
|
215
|
+
case "MOVE_DOWN":
|
|
216
|
+
if (activePane === 0) {
|
|
217
|
+
const nextIndex = selectedCategoryIndex === categories.length - 1 ? 0 : selectedCategoryIndex + 1;
|
|
218
|
+
state = {
|
|
219
|
+
...state,
|
|
220
|
+
selectedCategoryIndex: nextIndex,
|
|
221
|
+
selectedRuleIndex: 0,
|
|
222
|
+
ruleScroll: 0,
|
|
223
|
+
categoryScroll: updateScroll(nextIndex, state.categoryScroll, categoryListHeight),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
else if (activePane === 1) {
|
|
227
|
+
const nextIndex = selectedRuleIndex === currentCategoryRules.length - 1 ? 0 : selectedRuleIndex + 1;
|
|
228
|
+
state = {
|
|
229
|
+
...state,
|
|
230
|
+
selectedRuleIndex: nextIndex,
|
|
231
|
+
ruleScroll: updateScroll(nextIndex, state.ruleScroll, viewportHeight),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
render(state);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function getRuleStatus(ruleName, category, config) {
|
|
239
|
+
if (config.rules) {
|
|
240
|
+
let val = config.rules[ruleName];
|
|
241
|
+
if (val === undefined) {
|
|
242
|
+
const foundKey = Object.keys(config.rules).find((key) => key.endsWith(`/${ruleName}`));
|
|
243
|
+
if (foundKey)
|
|
244
|
+
val = config.rules[foundKey];
|
|
245
|
+
}
|
|
246
|
+
if (val !== undefined) {
|
|
247
|
+
const status = Array.isArray(val) ? val[0] : val;
|
|
248
|
+
return status;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (config.categories && config.categories[category]) {
|
|
252
|
+
return config.categories[category];
|
|
253
|
+
}
|
|
254
|
+
return "off";
|
|
255
|
+
}
|
|
256
|
+
function stripJsonComments(json) {
|
|
257
|
+
return json.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? "" : m));
|
|
258
|
+
}
|
|
259
|
+
function loadRules() {
|
|
260
|
+
let rulesData = [];
|
|
261
|
+
let config = {
|
|
262
|
+
rules: {},
|
|
263
|
+
categories: {},
|
|
264
|
+
};
|
|
265
|
+
let configPath = null;
|
|
266
|
+
try {
|
|
267
|
+
const raw = execSync(`npx -q --yes oxlint@${OXLINT_VERSION} --rules --format=json`, {
|
|
268
|
+
encoding: "utf8",
|
|
269
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
270
|
+
});
|
|
271
|
+
rulesData = JSON.parse(raw);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
state.message = "Error: Couldn't run 'npx oxlint'.";
|
|
275
|
+
state.messageType = "error";
|
|
276
|
+
exit(1);
|
|
277
|
+
}
|
|
278
|
+
const userConfigPath = argv[2];
|
|
279
|
+
if (userConfigPath && fs.existsSync(userConfigPath)) {
|
|
280
|
+
configPath = userConfigPath;
|
|
281
|
+
}
|
|
282
|
+
else if (fs.existsSync(".oxlintrc.json")) {
|
|
283
|
+
configPath = ".oxlintrc.json";
|
|
284
|
+
}
|
|
285
|
+
if (configPath) {
|
|
286
|
+
try {
|
|
287
|
+
config = JSON.parse(stripJsonComments(fs.readFileSync(configPath, "utf8")));
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
state.message = "Error: Couldn't parse config.";
|
|
291
|
+
state.messageType = "error";
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const map = {};
|
|
295
|
+
rulesData.forEach((rule) => {
|
|
296
|
+
const cat = rule.category || "Uncategorized";
|
|
297
|
+
if (!map[cat])
|
|
298
|
+
map[cat] = [];
|
|
299
|
+
const status = getRuleStatus(rule.value, cat, config);
|
|
300
|
+
map[cat].push({
|
|
301
|
+
...rule,
|
|
302
|
+
configStatus: status,
|
|
303
|
+
isActive: status === "error" || status === "warn",
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
const categories = Object.keys(map).toSorted();
|
|
307
|
+
return {
|
|
308
|
+
categories,
|
|
309
|
+
rulesByCategory: map,
|
|
310
|
+
config,
|
|
311
|
+
configPath,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function updateScroll(idx, currentScroll, viewHeight) {
|
|
315
|
+
if (idx < currentScroll)
|
|
316
|
+
return idx;
|
|
317
|
+
if (idx >= currentScroll + viewHeight)
|
|
318
|
+
return idx - viewHeight + 1;
|
|
319
|
+
return currentScroll;
|
|
320
|
+
}
|
|
321
|
+
function openUrl(url) {
|
|
322
|
+
if (!url)
|
|
323
|
+
return;
|
|
324
|
+
const openCmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
325
|
+
exec(`${openCmd} "${url}"`);
|
|
326
|
+
}
|
|
327
|
+
const write = (str) => stdout.write(str);
|
|
328
|
+
const enterAltScreen = () => write("\x1b[?1049h\x1b[?25l");
|
|
329
|
+
const exitAltScreen = () => write("\x1b[?1049l\x1b[?25h");
|
|
330
|
+
readline.emitKeypressEvents(stdin);
|
|
331
|
+
if (stdin.isTTY)
|
|
332
|
+
stdin.setRawMode(true);
|
|
333
|
+
stdin.on("keypress", (_, key) => {
|
|
334
|
+
const action = KEY_MAP[key.name] ||
|
|
335
|
+
(key.ctrl && key.name === "c" ? { type: "EXIT" } : KEY_MAP[key.sequence] || null);
|
|
336
|
+
execute(action);
|
|
337
|
+
});
|
|
338
|
+
stdout.on("resize", render);
|
|
339
|
+
enterAltScreen();
|
|
340
|
+
render(state);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { stdout } from "node:process";
|
|
2
|
+
export const COLORS = {
|
|
3
|
+
reset: "\x1b[0m",
|
|
4
|
+
dim: "\x1b[90m",
|
|
5
|
+
highlight: "\x1b[38;5;110m",
|
|
6
|
+
selectedBg: "\x1b[47m\x1b[30m",
|
|
7
|
+
borderActive: "\x1b[36m",
|
|
8
|
+
borderInactive: "\x1b[90m",
|
|
9
|
+
error: "\x1b[31m",
|
|
10
|
+
warn: "\x1b[33m",
|
|
11
|
+
success: "\x1b[32m",
|
|
12
|
+
info: "\x1b[34m",
|
|
13
|
+
};
|
|
14
|
+
function chunkString(str, len) {
|
|
15
|
+
if (!str)
|
|
16
|
+
return [];
|
|
17
|
+
const size = Math.ceil(str.length / len);
|
|
18
|
+
const r = Array(size);
|
|
19
|
+
for (let i = 0; i < size; i++)
|
|
20
|
+
r[i] = str.substring(i * len, (i + 1) * len);
|
|
21
|
+
return r;
|
|
22
|
+
}
|
|
23
|
+
function drawBox(buffer, x, y, width, height, title, items, selectedIndex, scrollOffset, isActive) {
|
|
24
|
+
const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
|
|
25
|
+
const titleClean = title.length > width - 6 ? title.substring(0, width - 7) + "…" : title;
|
|
26
|
+
const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(width + borderColor.length - 1, "─");
|
|
27
|
+
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
28
|
+
for (let i = 1; i < height - 1; i++) {
|
|
29
|
+
buffer.push(`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`);
|
|
30
|
+
}
|
|
31
|
+
buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`);
|
|
32
|
+
const innerHeight = height - 2;
|
|
33
|
+
items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
|
|
34
|
+
const absoluteIndex = scrollOffset + i;
|
|
35
|
+
const isRule = typeof item !== "string";
|
|
36
|
+
const rawText = isRule ? item.value : item;
|
|
37
|
+
let display = rawText.length > width - 4
|
|
38
|
+
? rawText.substring(0, width - 5) + "…"
|
|
39
|
+
: rawText.padEnd(width - 4);
|
|
40
|
+
let itemColor = COLORS.dim;
|
|
41
|
+
if (isRule) {
|
|
42
|
+
const ruleItem = item;
|
|
43
|
+
if (ruleItem.configStatus === "error")
|
|
44
|
+
itemColor = COLORS.error;
|
|
45
|
+
else if (ruleItem.configStatus === "warn")
|
|
46
|
+
itemColor = COLORS.warn;
|
|
47
|
+
else if (ruleItem.isActive)
|
|
48
|
+
itemColor = COLORS.success;
|
|
49
|
+
}
|
|
50
|
+
buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
|
|
51
|
+
if (absoluteIndex === selectedIndex) {
|
|
52
|
+
buffer.push(isActive
|
|
53
|
+
? `${COLORS.selectedBg}${display}${COLORS.reset}`
|
|
54
|
+
: `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
buffer.push(`${itemColor}${display}${COLORS.reset}`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function drawStats(buffer, x, y, width, height, rules) {
|
|
62
|
+
const borderColor = COLORS.borderInactive;
|
|
63
|
+
const topBorder = `${borderColor}┌─ STATS `.padEnd(width + borderColor.length - 1, "─");
|
|
64
|
+
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
65
|
+
for (let i = 1; i < height - 1; i++)
|
|
66
|
+
buffer.push(`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`);
|
|
67
|
+
buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`);
|
|
68
|
+
let counts = { error: 0, warn: 0, off: 0 };
|
|
69
|
+
rules.forEach((ruleItem) => {
|
|
70
|
+
if (ruleItem.configStatus === "error")
|
|
71
|
+
counts.error++;
|
|
72
|
+
else if (ruleItem.configStatus === "warn")
|
|
73
|
+
counts.warn++;
|
|
74
|
+
else
|
|
75
|
+
counts.off++;
|
|
76
|
+
});
|
|
77
|
+
const lines = [
|
|
78
|
+
{ label: "Error", count: counts.error, color: COLORS.error },
|
|
79
|
+
{ label: "Warn", count: counts.warn, color: COLORS.warn },
|
|
80
|
+
{ label: "Off", count: counts.off, color: COLORS.dim },
|
|
81
|
+
];
|
|
82
|
+
lines.forEach((line, i) => {
|
|
83
|
+
if (i < height - 2) {
|
|
84
|
+
const numStr = String(line.count).padStart(3);
|
|
85
|
+
const labelStr = line.label.padEnd(width - 8);
|
|
86
|
+
buffer.push(`\x1b[${y + 1 + i};${x + 2}H${line.color}${labelStr}${numStr}${COLORS.reset}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function drawDetails(buffer, x, y, width, height, rule, isActive) {
|
|
91
|
+
const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
|
|
92
|
+
const topBorder = `${borderColor}┌─ DETAILS `.padEnd(width + borderColor.length - 1, "─");
|
|
93
|
+
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
94
|
+
for (let i = 1; i < height - 1; i++)
|
|
95
|
+
buffer.push(`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`);
|
|
96
|
+
buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`);
|
|
97
|
+
if (!rule)
|
|
98
|
+
return;
|
|
99
|
+
let statusDisplay = rule.configStatus.toUpperCase();
|
|
100
|
+
if (rule.configStatus === "error")
|
|
101
|
+
statusDisplay = `${COLORS.error}${statusDisplay}${COLORS.reset}`;
|
|
102
|
+
else if (rule.configStatus === "warn")
|
|
103
|
+
statusDisplay = `${COLORS.warn}${statusDisplay}${COLORS.reset}`;
|
|
104
|
+
else
|
|
105
|
+
statusDisplay = `${COLORS.dim}${statusDisplay}${COLORS.reset}`;
|
|
106
|
+
const labels = [
|
|
107
|
+
["Name", rule.value],
|
|
108
|
+
["Status", statusDisplay],
|
|
109
|
+
["Category", rule.category],
|
|
110
|
+
["Scope", rule.scope],
|
|
111
|
+
["Fix", rule.fix || "N/A"],
|
|
112
|
+
["Default", rule.default ? "Yes" : "No"],
|
|
113
|
+
["Type-aware", rule.type_aware ? "Yes" : "No"],
|
|
114
|
+
["Docs", `Hit ${COLORS.highlight}ENTER${COLORS.reset} to open docs`],
|
|
115
|
+
];
|
|
116
|
+
let line = 0;
|
|
117
|
+
labels.forEach(([lbl, val]) => {
|
|
118
|
+
if (lbl === "Status" && line < height - 2) {
|
|
119
|
+
buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`);
|
|
120
|
+
line++;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const chunks = chunkString(String(val || "N/A"), width - 15);
|
|
124
|
+
chunks.forEach((chunk) => {
|
|
125
|
+
if (line < height - 2) {
|
|
126
|
+
buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`);
|
|
127
|
+
line++;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
export function render(state) {
|
|
133
|
+
const { columns = 80, rows = 24 } = stdout;
|
|
134
|
+
const currentCategory = state.categories[state.selectedCategoryIndex];
|
|
135
|
+
const rules = state.rulesByCategory[currentCategory] || [];
|
|
136
|
+
const rule = rules[state.selectedRuleIndex];
|
|
137
|
+
const boxHeight = rows - 5;
|
|
138
|
+
const categoriesColumnWidth = Math.floor(columns * 0.2);
|
|
139
|
+
const rulesColumnWidth = Math.floor(columns * 0.3);
|
|
140
|
+
const detailsColumnWidth = columns - categoriesColumnWidth - rulesColumnWidth - 2;
|
|
141
|
+
const statsHeight = 6;
|
|
142
|
+
const categoryListHeight = boxHeight - statsHeight;
|
|
143
|
+
const buffer = ["\x1b[H\x1b[J"];
|
|
144
|
+
drawBox(buffer, 1, 1, categoriesColumnWidth, categoryListHeight, "CATEGORIES", state.categories, state.selectedCategoryIndex, state.categoryScroll, state.activePane === 0);
|
|
145
|
+
drawStats(buffer, 1, 1 + categoryListHeight, categoriesColumnWidth, statsHeight, rules);
|
|
146
|
+
drawBox(buffer, categoriesColumnWidth + 1, 1, rulesColumnWidth, boxHeight, `RULES (${rules.length})`, rules, state.selectedRuleIndex, state.ruleScroll, state.activePane === 1);
|
|
147
|
+
drawDetails(buffer, categoriesColumnWidth + rulesColumnWidth + 1, 1, detailsColumnWidth, boxHeight, rule, state.activePane === 2);
|
|
148
|
+
const msgColor = COLORS[state.messageType] || COLORS.reset;
|
|
149
|
+
buffer.push(`\x1b[${rows - 3};2H${msgColor}● ${state.message}${COLORS.reset}`);
|
|
150
|
+
const footerConfig = state.configPath ? `Config: ${state.configPath}` : "No config loaded";
|
|
151
|
+
buffer.push(`\x1b[${rows - 1};2H${COLORS.dim}Arrows/HJKL: Nav | 1-3: Status | R: Lint | X: Run rule | Enter: Docs | Q: Quit | ${footerConfig}${COLORS.reset}`);
|
|
152
|
+
stdout.write(buffer.join(""));
|
|
153
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxlint-tui",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A Node TUI Oxlint rules and configuration browser",
|
|
5
|
-
"
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"configuration",
|
|
8
|
+
"lint",
|
|
9
|
+
"linter",
|
|
10
|
+
"oxlint",
|
|
11
|
+
"tui"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "holoflash",
|
|
6
15
|
"bin": {
|
|
7
|
-
"oxlint-
|
|
16
|
+
"oxlint-tui": "dist/index.js"
|
|
8
17
|
},
|
|
9
18
|
"files": [
|
|
10
|
-
"
|
|
19
|
+
"dist"
|
|
11
20
|
],
|
|
21
|
+
"type": "module",
|
|
12
22
|
"scripts": {
|
|
13
|
-
"
|
|
14
|
-
"dev": "
|
|
23
|
+
"prepare": "husky",
|
|
24
|
+
"dev": "tsx src/index.ts src/.oxlintrc.test.json",
|
|
25
|
+
"lint": "oxlint --type-aware --format=stylish",
|
|
26
|
+
"type-check": "tsgo --noEmit",
|
|
27
|
+
"format": "oxfmt",
|
|
28
|
+
"format-check": "oxfmt --check",
|
|
29
|
+
"build": "rm -rf dist && tsc",
|
|
30
|
+
"build-run": "npm run build && node dist/index.js src/.oxlintrc.test.json"
|
|
15
31
|
},
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "25.0.10",
|
|
34
|
+
"@typescript/native-preview": "7.0.0-dev.20260124.1",
|
|
35
|
+
"husky": "9.1.7",
|
|
36
|
+
"oxfmt": "0.26.0",
|
|
37
|
+
"oxlint": "1.41.0",
|
|
38
|
+
"oxlint-tsgolint": "0.11.1",
|
|
39
|
+
"tsx": "4.21.0",
|
|
40
|
+
"typescript": "5.9.3"
|
|
24
41
|
},
|
|
25
|
-
"author": "holoflash",
|
|
26
|
-
"license": "MIT",
|
|
27
42
|
"engines": {
|
|
28
43
|
"node": ">=16"
|
|
29
44
|
}
|
package/tui.js
DELETED
|
@@ -1,640 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { execSync, exec, spawn } from "node:child_process";
|
|
4
|
-
import { stdout, stdin, exit, platform, argv } from "node:process";
|
|
5
|
-
import readline from "readline";
|
|
6
|
-
import fs from "node:fs";
|
|
7
|
-
|
|
8
|
-
const OXLINT_VERSION = "1.41.0";
|
|
9
|
-
const TSGOLINT_VERSION = "0.11.1";
|
|
10
|
-
|
|
11
|
-
const COLORS = {
|
|
12
|
-
reset: "\x1b[0m",
|
|
13
|
-
dim: "\x1b[90m",
|
|
14
|
-
highlight: "\x1b[38;5;110m",
|
|
15
|
-
selectedBg: "\x1b[47m\x1b[30m",
|
|
16
|
-
borderActive: "\x1b[36m",
|
|
17
|
-
borderInactive: "\x1b[90m",
|
|
18
|
-
error: "\x1b[31m",
|
|
19
|
-
warn: "\x1b[33m",
|
|
20
|
-
success: "\x1b[32m",
|
|
21
|
-
info: "\x1b[34m",
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const KEY_MAP = {
|
|
25
|
-
k: { type: "MOVE_UP" },
|
|
26
|
-
up: { type: "MOVE_UP" },
|
|
27
|
-
down: { type: "MOVE_DOWN" },
|
|
28
|
-
j: { type: "MOVE_DOWN" },
|
|
29
|
-
left: { type: "MOVE_LEFT" },
|
|
30
|
-
h: { type: "MOVE_LEFT" },
|
|
31
|
-
right: { type: "MOVE_RIGHT" },
|
|
32
|
-
l: { type: "MOVE_RIGHT" },
|
|
33
|
-
return: { type: "OPEN_DOCS" },
|
|
34
|
-
enter: { type: "OPEN_DOCS" },
|
|
35
|
-
1: { type: "SET_STATUS", value: "off" },
|
|
36
|
-
2: { type: "SET_STATUS", value: "warn" },
|
|
37
|
-
3: { type: "SET_STATUS", value: "error" },
|
|
38
|
-
q: { type: "EXIT" },
|
|
39
|
-
r: { type: "RUN_LINT" },
|
|
40
|
-
x: { type: "RUN_SINGLE_RULE" },
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
let state = {
|
|
44
|
-
activePane: 0,
|
|
45
|
-
selectedCatIdx: 0,
|
|
46
|
-
selectedRuleIdx: 0,
|
|
47
|
-
scrollCat: 0,
|
|
48
|
-
scrollRule: 0,
|
|
49
|
-
isLinting: false,
|
|
50
|
-
message: "oxlint-tui",
|
|
51
|
-
messageType: "dim",
|
|
52
|
-
...loadRules(),
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
// TODO: Nukes comments in the json file. Find the most minimal way to avoid this, while preserving formatting
|
|
56
|
-
function updateConfig(rule, newStatus) {
|
|
57
|
-
if (!state.configPath) return;
|
|
58
|
-
try {
|
|
59
|
-
if (!state.config.rules) state.config.rules = {};
|
|
60
|
-
const ruleName = rule.value;
|
|
61
|
-
const canonicalKey =
|
|
62
|
-
rule.scope === "oxc2" || rule.scope === "eslint"
|
|
63
|
-
? ruleName
|
|
64
|
-
: `${rule.scope}/${ruleName}`;
|
|
65
|
-
const existingKey = Object.keys(state.config.rules).find(
|
|
66
|
-
(key) =>
|
|
67
|
-
key === canonicalKey ||
|
|
68
|
-
key === ruleName ||
|
|
69
|
-
key.endsWith(`/${ruleName}`),
|
|
70
|
-
);
|
|
71
|
-
const targetKey = existingKey || canonicalKey;
|
|
72
|
-
state.config.rules[targetKey] = newStatus;
|
|
73
|
-
fs.writeFileSync(
|
|
74
|
-
state.configPath,
|
|
75
|
-
JSON.stringify(state.config, null, 2),
|
|
76
|
-
"utf8",
|
|
77
|
-
);
|
|
78
|
-
} catch (e) {
|
|
79
|
-
state.message = "Failed to write config file";
|
|
80
|
-
state.messageType = "error";
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function runLint({ rule = null } = {}) {
|
|
85
|
-
if (state.isLinting) return;
|
|
86
|
-
|
|
87
|
-
state.isLinting = true;
|
|
88
|
-
|
|
89
|
-
let ruleName = rule ? `${rule.scope}/${rule.value}` : null;
|
|
90
|
-
|
|
91
|
-
const typeAware = rule
|
|
92
|
-
? rule.type_aware
|
|
93
|
-
: Object.values(state.rulesByCategory)
|
|
94
|
-
.flat()
|
|
95
|
-
.some((rule) => rule.isActive && rule.type_aware === true);
|
|
96
|
-
|
|
97
|
-
state.message = "Linting";
|
|
98
|
-
if (ruleName) state.message += ` [${ruleName}]`;
|
|
99
|
-
if (typeAware) state.message += " with --type-aware";
|
|
100
|
-
state.message += "...";
|
|
101
|
-
|
|
102
|
-
state.messageType = "info";
|
|
103
|
-
|
|
104
|
-
render();
|
|
105
|
-
|
|
106
|
-
const npxCmd = platform === "win32" ? "npx.cmd" : "npx";
|
|
107
|
-
|
|
108
|
-
const args = ["-q", "--yes", "--package", `oxlint@${OXLINT_VERSION}`];
|
|
109
|
-
|
|
110
|
-
if (typeAware) {
|
|
111
|
-
args.push("--package", `oxlint-tsgolint@${TSGOLINT_VERSION}`);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
args.push("--", "oxlint");
|
|
115
|
-
|
|
116
|
-
if (typeAware) {
|
|
117
|
-
args.push("--type-aware");
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (ruleName) {
|
|
121
|
-
args.push("-A", "all", "-D", ruleName);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const child = spawn(npxCmd, args);
|
|
125
|
-
|
|
126
|
-
let stdoutData = "";
|
|
127
|
-
let stderrData = "";
|
|
128
|
-
|
|
129
|
-
child.stdout.on("data", (data) => {
|
|
130
|
-
stdoutData += data;
|
|
131
|
-
});
|
|
132
|
-
child.stderr.on("data", (data) => {
|
|
133
|
-
stderrData += data;
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
child.on("close", (code) => {
|
|
137
|
-
state.isLinting = false;
|
|
138
|
-
|
|
139
|
-
const fullOutput = stdoutData + stderrData;
|
|
140
|
-
const summaryMatch = fullOutput.match(
|
|
141
|
-
/Found (\d+) warnings? and (\d+) errors?/i,
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
if (summaryMatch) {
|
|
145
|
-
const errors = parseInt(summaryMatch[2]);
|
|
146
|
-
state.message = ruleName
|
|
147
|
-
? `[${ruleName}] Found ${errors} issue${errors === 1 ? "" : "s"}`
|
|
148
|
-
: (state.message = summaryMatch[0]);
|
|
149
|
-
state.messageType = errors > 0 ? "error" : "warn";
|
|
150
|
-
} else if (
|
|
151
|
-
stdoutData.toLowerCase().includes("finished") ||
|
|
152
|
-
(code === 0 && stdoutData.length < 200)
|
|
153
|
-
) {
|
|
154
|
-
state.message = "Linting passed! 0 issues found.";
|
|
155
|
-
if (ruleName) state.message = `[${ruleName}] ${state.message}`;
|
|
156
|
-
state.messageType = "success";
|
|
157
|
-
} else {
|
|
158
|
-
const cleanError = stderrData
|
|
159
|
-
.split("\n")
|
|
160
|
-
.filter(
|
|
161
|
-
(l) =>
|
|
162
|
-
!l.includes("experimental") &&
|
|
163
|
-
!l.includes("Breaking changes") &&
|
|
164
|
-
l.trim() !== "",
|
|
165
|
-
)
|
|
166
|
-
.join(" ");
|
|
167
|
-
|
|
168
|
-
state.message = cleanError
|
|
169
|
-
? `Error: ${cleanError.substring(0, 50)}...`
|
|
170
|
-
: "Lint failed";
|
|
171
|
-
state.messageType = "error";
|
|
172
|
-
}
|
|
173
|
-
render();
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function execute(action) {
|
|
178
|
-
if (!action) return;
|
|
179
|
-
|
|
180
|
-
const {
|
|
181
|
-
categories,
|
|
182
|
-
rulesByCategory,
|
|
183
|
-
selectedCatIdx,
|
|
184
|
-
selectedRuleIdx,
|
|
185
|
-
activePane,
|
|
186
|
-
} = state;
|
|
187
|
-
const currentCat = categories[selectedCatIdx];
|
|
188
|
-
const currentRules = rulesByCategory[currentCat] || [];
|
|
189
|
-
const viewHeight = stdout.rows - 8;
|
|
190
|
-
const statsHeight = 7;
|
|
191
|
-
const catViewHeight = viewHeight - statsHeight;
|
|
192
|
-
|
|
193
|
-
switch (action.type) {
|
|
194
|
-
case "EXIT":
|
|
195
|
-
exitAltScreen();
|
|
196
|
-
exit(0);
|
|
197
|
-
return;
|
|
198
|
-
|
|
199
|
-
case "RUN_LINT":
|
|
200
|
-
runLint();
|
|
201
|
-
return;
|
|
202
|
-
|
|
203
|
-
case "RUN_SINGLE_RULE": {
|
|
204
|
-
const rule = currentRules[selectedRuleIdx];
|
|
205
|
-
if (rule) runLint({ rule });
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
case "OPEN_DOCS": {
|
|
210
|
-
if (activePane === 1) {
|
|
211
|
-
const rule = currentRules[selectedRuleIdx];
|
|
212
|
-
if (rule) openUrl(rule.docs_url || rule.url);
|
|
213
|
-
}
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
case "SET_STATUS": {
|
|
218
|
-
if (activePane !== 1) return;
|
|
219
|
-
const rule = currentRules[selectedRuleIdx];
|
|
220
|
-
if (!rule) return;
|
|
221
|
-
updateConfig(rule, action.value);
|
|
222
|
-
const updatedRules = [...currentRules];
|
|
223
|
-
updatedRules[selectedRuleIdx] = {
|
|
224
|
-
...rule,
|
|
225
|
-
configStatus: action.value,
|
|
226
|
-
isActive: action.value === "error" || action.value === "warn",
|
|
227
|
-
};
|
|
228
|
-
state = {
|
|
229
|
-
...state,
|
|
230
|
-
message: `Rule '${rule.value}' set to: ${action.value}`,
|
|
231
|
-
messageType: "info",
|
|
232
|
-
rulesByCategory: {
|
|
233
|
-
...rulesByCategory,
|
|
234
|
-
[currentCat]: updatedRules,
|
|
235
|
-
},
|
|
236
|
-
};
|
|
237
|
-
render();
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
case "MOVE_RIGHT":
|
|
242
|
-
if (activePane !== 1) {
|
|
243
|
-
state = { ...state, activePane: activePane + 1 };
|
|
244
|
-
render();
|
|
245
|
-
}
|
|
246
|
-
return;
|
|
247
|
-
|
|
248
|
-
case "MOVE_LEFT":
|
|
249
|
-
if (activePane !== 0) {
|
|
250
|
-
state = { ...state, activePane: activePane - 1 };
|
|
251
|
-
render();
|
|
252
|
-
}
|
|
253
|
-
return;
|
|
254
|
-
|
|
255
|
-
case "MOVE_UP":
|
|
256
|
-
if (activePane === 0) {
|
|
257
|
-
const nextIdx =
|
|
258
|
-
selectedCatIdx === 0 ? categories.length - 1 : selectedCatIdx - 1;
|
|
259
|
-
state = {
|
|
260
|
-
...state,
|
|
261
|
-
selectedCatIdx: nextIdx,
|
|
262
|
-
selectedRuleIdx: 0,
|
|
263
|
-
scrollRule: 0,
|
|
264
|
-
scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
|
|
265
|
-
};
|
|
266
|
-
} else if (activePane === 1) {
|
|
267
|
-
const nextIdx =
|
|
268
|
-
selectedRuleIdx === 0 ? currentRules.length - 1 : selectedRuleIdx - 1;
|
|
269
|
-
state = {
|
|
270
|
-
...state,
|
|
271
|
-
selectedRuleIdx: nextIdx,
|
|
272
|
-
scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
render();
|
|
276
|
-
return;
|
|
277
|
-
|
|
278
|
-
case "MOVE_DOWN":
|
|
279
|
-
if (activePane === 0) {
|
|
280
|
-
const nextIdx =
|
|
281
|
-
selectedCatIdx === categories.length - 1 ? 0 : selectedCatIdx + 1;
|
|
282
|
-
state = {
|
|
283
|
-
...state,
|
|
284
|
-
selectedCatIdx: nextIdx,
|
|
285
|
-
selectedRuleIdx: 0,
|
|
286
|
-
scrollRule: 0,
|
|
287
|
-
scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
|
|
288
|
-
};
|
|
289
|
-
} else if (activePane === 1) {
|
|
290
|
-
const nextIdx =
|
|
291
|
-
selectedRuleIdx === currentRules.length - 1 ? 0 : selectedRuleIdx + 1;
|
|
292
|
-
state = {
|
|
293
|
-
...state,
|
|
294
|
-
selectedRuleIdx: nextIdx,
|
|
295
|
-
scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
render();
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function getRuleStatus(ruleName, category, config) {
|
|
304
|
-
if (config.rules) {
|
|
305
|
-
let val = config.rules[ruleName];
|
|
306
|
-
if (val === undefined) {
|
|
307
|
-
const foundKey = Object.keys(config.rules).find((key) =>
|
|
308
|
-
key.endsWith(`/${ruleName}`),
|
|
309
|
-
);
|
|
310
|
-
if (foundKey) val = config.rules[foundKey];
|
|
311
|
-
}
|
|
312
|
-
if (val !== undefined) return Array.isArray(val) ? val[0] : val;
|
|
313
|
-
}
|
|
314
|
-
return (config.categories && config.categories[category]) || "off";
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function stripJsonComments(json) {
|
|
318
|
-
return json.replace(
|
|
319
|
-
/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g,
|
|
320
|
-
(m, g) => (g ? "" : m),
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function loadRules() {
|
|
325
|
-
let rulesData;
|
|
326
|
-
let config = {
|
|
327
|
-
rules: {},
|
|
328
|
-
categories: {},
|
|
329
|
-
};
|
|
330
|
-
let configPath = null;
|
|
331
|
-
|
|
332
|
-
try {
|
|
333
|
-
const raw = execSync(
|
|
334
|
-
`npx -q --yes oxlint@${OXLINT_VERSION} --rules --format=json`,
|
|
335
|
-
{
|
|
336
|
-
encoding: "utf8",
|
|
337
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
338
|
-
},
|
|
339
|
-
);
|
|
340
|
-
rulesData = JSON.parse(raw);
|
|
341
|
-
} catch (e) {
|
|
342
|
-
console.error(
|
|
343
|
-
`${COLORS.error}Error: Could not run 'npx oxlint'.${COLORS.reset}`,
|
|
344
|
-
);
|
|
345
|
-
exit(1);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const userConfigPath = argv[2];
|
|
349
|
-
if (userConfigPath && fs.existsSync(userConfigPath)) {
|
|
350
|
-
configPath = userConfigPath;
|
|
351
|
-
} else if (fs.existsSync(".oxlintrc.json")) {
|
|
352
|
-
configPath = ".oxlintrc.json";
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (configPath) {
|
|
356
|
-
try {
|
|
357
|
-
config = JSON.parse(
|
|
358
|
-
stripJsonComments(fs.readFileSync(configPath, "utf8")),
|
|
359
|
-
);
|
|
360
|
-
} catch (e) {}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const map = {};
|
|
364
|
-
rulesData.forEach((rule) => {
|
|
365
|
-
const cat = rule.category || "Uncategorized";
|
|
366
|
-
if (!map[cat]) map[cat] = [];
|
|
367
|
-
const status = getRuleStatus(rule.value, cat, config);
|
|
368
|
-
map[cat].push({
|
|
369
|
-
...rule,
|
|
370
|
-
configStatus: status,
|
|
371
|
-
isActive: status === "error" || status === "warn",
|
|
372
|
-
});
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
const categories = Object.keys(map).sort();
|
|
376
|
-
return {
|
|
377
|
-
categories,
|
|
378
|
-
rulesByCategory: map,
|
|
379
|
-
config,
|
|
380
|
-
configPath,
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function updateScroll(idx, currentScroll, viewHeight) {
|
|
385
|
-
if (idx < currentScroll) return idx;
|
|
386
|
-
if (idx >= currentScroll + viewHeight) return idx - viewHeight + 1;
|
|
387
|
-
return currentScroll;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function openUrl(url) {
|
|
391
|
-
if (!url) return;
|
|
392
|
-
const cmd =
|
|
393
|
-
platform === "darwin"
|
|
394
|
-
? "open"
|
|
395
|
-
: platform === "win32"
|
|
396
|
-
? "start"
|
|
397
|
-
: "xdg-open";
|
|
398
|
-
exec(`${cmd} "${url}"`);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function chunkString(str, len) {
|
|
402
|
-
if (!str) return [];
|
|
403
|
-
const size = Math.ceil(str.length / len);
|
|
404
|
-
const r = Array(size);
|
|
405
|
-
for (let i = 0; i < size; i++) r[i] = str.substring(i * len, (i + 1) * len);
|
|
406
|
-
return r;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const write = (str) => stdout.write(str);
|
|
410
|
-
const enterAltScreen = () => write("\x1b[?1049h\x1b[?25l");
|
|
411
|
-
const exitAltScreen = () => write("\x1b[?1049l\x1b[?25h");
|
|
412
|
-
|
|
413
|
-
function drawBox(
|
|
414
|
-
buffer,
|
|
415
|
-
x,
|
|
416
|
-
y,
|
|
417
|
-
width,
|
|
418
|
-
height,
|
|
419
|
-
title,
|
|
420
|
-
items,
|
|
421
|
-
selectedIdx,
|
|
422
|
-
scrollOffset,
|
|
423
|
-
isActive,
|
|
424
|
-
) {
|
|
425
|
-
const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
|
|
426
|
-
const titleClean =
|
|
427
|
-
title.length > width - 6 ? title.substring(0, width - 7) + "…" : title;
|
|
428
|
-
const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(
|
|
429
|
-
width + borderColor.length - 1,
|
|
430
|
-
"─",
|
|
431
|
-
);
|
|
432
|
-
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
433
|
-
|
|
434
|
-
for (let i = 1; i < height - 1; i++) {
|
|
435
|
-
buffer.push(
|
|
436
|
-
`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
|
|
437
|
-
);
|
|
438
|
-
}
|
|
439
|
-
buffer.push(
|
|
440
|
-
`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
|
|
441
|
-
);
|
|
442
|
-
|
|
443
|
-
const innerHeight = height - 2;
|
|
444
|
-
items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
|
|
445
|
-
const absIdx = scrollOffset + i;
|
|
446
|
-
const rawText = (item.value || item).toString();
|
|
447
|
-
let display =
|
|
448
|
-
rawText.length > width - 4
|
|
449
|
-
? rawText.substring(0, width - 5) + "…"
|
|
450
|
-
: rawText.padEnd(width - 4);
|
|
451
|
-
let itemColor = COLORS.dim;
|
|
452
|
-
if (item.configStatus === "error") itemColor = COLORS.error;
|
|
453
|
-
else if (item.configStatus === "warn") itemColor = COLORS.warn;
|
|
454
|
-
else if (item.isActive) itemColor = COLORS.success;
|
|
455
|
-
|
|
456
|
-
buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
|
|
457
|
-
if (absIdx === selectedIdx) {
|
|
458
|
-
buffer.push(
|
|
459
|
-
isActive
|
|
460
|
-
? `${COLORS.selectedBg}${display}${COLORS.reset}`
|
|
461
|
-
: `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`,
|
|
462
|
-
);
|
|
463
|
-
} else {
|
|
464
|
-
buffer.push(`${itemColor}${display}${COLORS.reset}`);
|
|
465
|
-
}
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function drawStats(buffer, x, y, width, height, rules) {
|
|
470
|
-
const borderColor = COLORS.borderInactive;
|
|
471
|
-
const topBorder = `${borderColor}┌─ STATS `.padEnd(
|
|
472
|
-
width + borderColor.length - 1,
|
|
473
|
-
"─",
|
|
474
|
-
);
|
|
475
|
-
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
476
|
-
for (let i = 1; i < height - 1; i++)
|
|
477
|
-
buffer.push(
|
|
478
|
-
`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
|
|
479
|
-
);
|
|
480
|
-
buffer.push(
|
|
481
|
-
`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
|
|
482
|
-
);
|
|
483
|
-
|
|
484
|
-
let counts = { error: 0, warn: 0, off: 0 };
|
|
485
|
-
rules.forEach((r) => {
|
|
486
|
-
if (r.configStatus === "error") counts.error++;
|
|
487
|
-
else if (r.configStatus === "warn") counts.warn++;
|
|
488
|
-
else counts.off++;
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
const lines = [
|
|
492
|
-
{ label: "Error", count: counts.error, color: COLORS.error },
|
|
493
|
-
{ label: "Warn", count: counts.warn, color: COLORS.warn },
|
|
494
|
-
{ label: "Off", count: counts.off, color: COLORS.dim },
|
|
495
|
-
];
|
|
496
|
-
|
|
497
|
-
lines.forEach((line, i) => {
|
|
498
|
-
if (i < height - 2) {
|
|
499
|
-
const numStr = String(line.count).padStart(3);
|
|
500
|
-
const labelStr = line.label.padEnd(width - 8);
|
|
501
|
-
buffer.push(
|
|
502
|
-
`\x1b[${y + 1 + i};${x + 2}H${line.color}${labelStr}${numStr}${COLORS.reset}`,
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function drawDetails(buffer, x, y, width, height, rule, isActive) {
|
|
509
|
-
const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
|
|
510
|
-
const topBorder = `${borderColor}┌─ DETAILS `.padEnd(
|
|
511
|
-
width + borderColor.length - 1,
|
|
512
|
-
"─",
|
|
513
|
-
);
|
|
514
|
-
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
515
|
-
for (let i = 1; i < height - 1; i++)
|
|
516
|
-
buffer.push(
|
|
517
|
-
`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
|
|
518
|
-
);
|
|
519
|
-
buffer.push(
|
|
520
|
-
`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
|
|
521
|
-
);
|
|
522
|
-
|
|
523
|
-
if (!rule) return;
|
|
524
|
-
|
|
525
|
-
let statusDisplay = rule.configStatus.toUpperCase();
|
|
526
|
-
if (rule.configStatus === "error")
|
|
527
|
-
statusDisplay = `${COLORS.error}${statusDisplay}${COLORS.reset}`;
|
|
528
|
-
else if (rule.configStatus === "warn")
|
|
529
|
-
statusDisplay = `${COLORS.warn}${statusDisplay}${COLORS.reset}`;
|
|
530
|
-
else statusDisplay = `${COLORS.dim}${statusDisplay}${COLORS.reset}`;
|
|
531
|
-
|
|
532
|
-
const labels = [
|
|
533
|
-
["Name", rule.value],
|
|
534
|
-
["Status", statusDisplay],
|
|
535
|
-
["Category", rule.category],
|
|
536
|
-
["Scope", rule.scope],
|
|
537
|
-
["Fix", rule.fix],
|
|
538
|
-
["Default", rule.default ? "Yes" : "No"],
|
|
539
|
-
["Type-aware", rule.type_aware ? "Yes" : "No"],
|
|
540
|
-
["Docs", `Hit ${COLORS.highlight}ENTER${COLORS.reset} to open docs`],
|
|
541
|
-
];
|
|
542
|
-
|
|
543
|
-
let line = 0;
|
|
544
|
-
labels.forEach(([lbl, val]) => {
|
|
545
|
-
if (lbl === "Status" && line < height - 2) {
|
|
546
|
-
buffer.push(
|
|
547
|
-
`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`,
|
|
548
|
-
);
|
|
549
|
-
line++;
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
const chunks = chunkString(String(val || "N/A"), width - 15);
|
|
553
|
-
chunks.forEach((chunk) => {
|
|
554
|
-
if (line < height - 2) {
|
|
555
|
-
buffer.push(
|
|
556
|
-
`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`,
|
|
557
|
-
);
|
|
558
|
-
line++;
|
|
559
|
-
}
|
|
560
|
-
});
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function render() {
|
|
565
|
-
const { columns, rows } = stdout;
|
|
566
|
-
const currentCat = state.categories[state.selectedCatIdx];
|
|
567
|
-
const rules = state.rulesByCategory[currentCat] || [];
|
|
568
|
-
const rule = rules[state.selectedRuleIdx];
|
|
569
|
-
const boxHeight = rows - 5;
|
|
570
|
-
const col1W = Math.floor(columns * 0.2);
|
|
571
|
-
const col2W = Math.floor(columns * 0.3);
|
|
572
|
-
const col3W = columns - col1W - col2W - 2;
|
|
573
|
-
const statsHeight = 6;
|
|
574
|
-
const catListHeight = boxHeight - statsHeight;
|
|
575
|
-
|
|
576
|
-
const buffer = ["\x1b[H\x1b[J"];
|
|
577
|
-
drawBox(
|
|
578
|
-
buffer,
|
|
579
|
-
1,
|
|
580
|
-
1,
|
|
581
|
-
col1W,
|
|
582
|
-
catListHeight,
|
|
583
|
-
"CATEGORIES",
|
|
584
|
-
state.categories,
|
|
585
|
-
state.selectedCatIdx,
|
|
586
|
-
state.scrollCat,
|
|
587
|
-
state.activePane === 0,
|
|
588
|
-
);
|
|
589
|
-
drawStats(buffer, 1, 1 + catListHeight, col1W, statsHeight, rules);
|
|
590
|
-
drawBox(
|
|
591
|
-
buffer,
|
|
592
|
-
col1W + 1,
|
|
593
|
-
1,
|
|
594
|
-
col2W,
|
|
595
|
-
boxHeight,
|
|
596
|
-
`RULES (${rules.length})`,
|
|
597
|
-
rules,
|
|
598
|
-
state.selectedRuleIdx,
|
|
599
|
-
state.scrollRule,
|
|
600
|
-
state.activePane === 1,
|
|
601
|
-
);
|
|
602
|
-
drawDetails(
|
|
603
|
-
buffer,
|
|
604
|
-
col1W + col2W + 1,
|
|
605
|
-
1,
|
|
606
|
-
col3W,
|
|
607
|
-
boxHeight,
|
|
608
|
-
rule,
|
|
609
|
-
state.activePane === 2,
|
|
610
|
-
);
|
|
611
|
-
|
|
612
|
-
const msgColor = COLORS[state.messageType] || COLORS.reset;
|
|
613
|
-
buffer.push(
|
|
614
|
-
`\x1b[${rows - 3};2H${msgColor}● ${state.message}${COLORS.reset}`,
|
|
615
|
-
);
|
|
616
|
-
|
|
617
|
-
const footerConfig = state.configPath
|
|
618
|
-
? `Config: ${state.configPath}`
|
|
619
|
-
: "No config loaded";
|
|
620
|
-
buffer.push(
|
|
621
|
-
`\x1b[${rows - 1};2H${COLORS.dim}Arrows/HJKL: Nav | 1-3: Status | R: Lint | X: Run rule | Enter: Docs | Q: Quit | ${footerConfig}${COLORS.reset}`,
|
|
622
|
-
);
|
|
623
|
-
write(buffer.join(""));
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
readline.emitKeypressEvents(stdin);
|
|
627
|
-
if (stdin.isTTY) stdin.setRawMode(true);
|
|
628
|
-
|
|
629
|
-
stdin.on("keypress", (_, key) => {
|
|
630
|
-
const action =
|
|
631
|
-
KEY_MAP[key.name] ||
|
|
632
|
-
(key.ctrl && key.name === "c"
|
|
633
|
-
? { type: "EXIT" }
|
|
634
|
-
: KEY_MAP[key.sequence] || null);
|
|
635
|
-
execute(action);
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
stdout.on("resize", render);
|
|
639
|
-
enterAltScreen();
|
|
640
|
-
render();
|