oxlint-tui 1.0.5 → 1.0.7
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 +9 -9
- package/package.json +2 -2
- package/tui.js +94 -70
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# oxlint-tui
|
|
2
2
|
|
|
3
|
-
A lightweight, dependency-free Node.js Terminal User Interface (TUI) for browsing and visualizing [oxlint](https://github.com/oxc-project/oxc) rules.
|
|
3
|
+
A lightweight, dependency-free Node.js Terminal User Interface (TUI) for browsing, toggling and visualizing [oxlint](https://github.com/oxc-project/oxc) rules.
|
|
4
4
|
|
|
5
|
-
It automatically loads your local configuration to show you the status of the rules toggled in your project.
|
|
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.
|
|
6
|
+
|
|
7
|
+
**NOTE**: At the moment, comments will be erased from your configuration file when adding or toggling rules.
|
|
6
8
|
|
|
7
9
|

|
|
8
10
|
|
|
@@ -17,11 +19,6 @@ Configuring linters often involves jumping between your editor, a massive JSON f
|
|
|
17
19
|
* **View Docs**: Press <kbd>ENTER</kbd> on any rule to open its official documentation in your browser.
|
|
18
20
|
* **Zero Dependencies**: Written in pure Node.js without any heavy TUI libraries.
|
|
19
21
|
|
|
20
|
-
## Important note
|
|
21
|
-
|
|
22
|
-
Documentation links and information about fixability are not available in older versions of oxlint.
|
|
23
|
-
This tool was developed using oxlint version 1.41.0.
|
|
24
|
-
|
|
25
22
|
## Usage
|
|
26
23
|
|
|
27
24
|
### Quick Start (via npx)
|
|
@@ -57,7 +54,10 @@ oxlint-tui
|
|
|
57
54
|
| **↑** / **k** | Move selection Up |
|
|
58
55
|
| **↓** / **j** | Move selection Down |
|
|
59
56
|
| **←** / **h** | Move focus Left (Categories <-> Rules) |
|
|
60
|
-
| **→** / **l** | Move focus Right (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
61
|
| **Enter** | Open Rule Documentation in Browser |
|
|
62
62
|
| **q** / **Esc** | Quit |
|
|
63
63
|
|
|
@@ -68,7 +68,7 @@ oxlint-tui
|
|
|
68
68
|
|
|
69
69
|
## Roadmap
|
|
70
70
|
|
|
71
|
-
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
|
|
71
|
+
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.
|
|
72
72
|
|
|
73
73
|
If you're willing and able, please feel free to [contribute to this project](https://github.com/holoflash/oxlint-tui/blob/main/CONTRIBUTING.md).
|
|
74
74
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxlint-tui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "A Node TUI Oxlint rules and configuration browser",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
13
|
"start": "node tui.js",
|
|
14
|
-
"
|
|
14
|
+
"dev": "node tui.js .oxlintrc.example.json"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"oxlint",
|
package/tui.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { execSync, exec } from 'node:child_process';
|
|
4
4
|
import { stdout, stdin, exit, platform, argv } from 'node:process';
|
|
5
|
-
import readline from '
|
|
5
|
+
import readline from 'readline';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
|
|
8
|
+
const OXLINT_VERSION = "1.41.0"
|
|
9
|
+
|
|
8
10
|
const COLORS = {
|
|
9
11
|
reset: '\x1b[0m',
|
|
10
12
|
dim: '\x1b[90m',
|
|
@@ -28,10 +30,38 @@ const KEY_MAP = {
|
|
|
28
30
|
'l': { type: 'MOVE_RIGHT' },
|
|
29
31
|
'return': { type: 'OPEN_DOCS' },
|
|
30
32
|
'enter': { type: 'OPEN_DOCS' },
|
|
33
|
+
'1': { type: 'SET_STATUS', value: 'off' },
|
|
34
|
+
'2': { type: 'SET_STATUS', value: 'warn' },
|
|
35
|
+
'3': { type: 'SET_STATUS', value: 'error' },
|
|
31
36
|
'q': { type: 'EXIT' },
|
|
32
37
|
'escape': { type: 'EXIT' }
|
|
33
38
|
};
|
|
34
39
|
|
|
40
|
+
// TODO: Nukes comments in the json file. Find the most minimal way to avoid this, while preserveing formatting
|
|
41
|
+
function updateConfig(rule, newStatus) {
|
|
42
|
+
if (!state.configPath) return;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
if (!state.config.rules) state.config.rules = {};
|
|
46
|
+
const ruleName = rule.value;
|
|
47
|
+
const scope = rule.scope;
|
|
48
|
+
const canonicalKey = scope === 'oxc' ? ruleName : `${scope}/${ruleName}`;
|
|
49
|
+
|
|
50
|
+
const existingKey = Object.keys(state.config.rules).find(key =>
|
|
51
|
+
key === canonicalKey ||
|
|
52
|
+
key === ruleName ||
|
|
53
|
+
key.endsWith(`/${ruleName}`)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const targetKey = existingKey || canonicalKey;
|
|
57
|
+
state.config.rules[targetKey] = newStatus;
|
|
58
|
+
|
|
59
|
+
fs.writeFileSync(state.configPath, JSON.stringify(state.config, null, 2), 'utf8');
|
|
60
|
+
} catch {
|
|
61
|
+
// TODO: Show errors in the tui
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
function reducer(state, action) {
|
|
36
66
|
const { categories, rulesByCategory, selectedCatIdx, selectedRuleIdx, activePane } = state;
|
|
37
67
|
const currentCat = categories[selectedCatIdx];
|
|
@@ -42,11 +72,38 @@ function reducer(state, action) {
|
|
|
42
72
|
const catViewHeight = viewHeight - statsHeight;
|
|
43
73
|
|
|
44
74
|
switch (action.type) {
|
|
75
|
+
case 'SET_STATUS': {
|
|
76
|
+
if (activePane !== 1) return state;
|
|
77
|
+
const rule = currentRules[selectedRuleIdx];
|
|
78
|
+
if (!rule) return state;
|
|
79
|
+
|
|
80
|
+
updateConfig(rule, action.value);
|
|
81
|
+
|
|
82
|
+
const updatedRules = [...currentRules];
|
|
83
|
+
updatedRules[selectedRuleIdx] = {
|
|
84
|
+
...rule,
|
|
85
|
+
configStatus: action.value,
|
|
86
|
+
isActive: action.value === 'error' || action.value === 'warn'
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
...state,
|
|
91
|
+
rulesByCategory: {
|
|
92
|
+
...rulesByCategory,
|
|
93
|
+
[currentCat]: updatedRules
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
45
98
|
case 'MOVE_RIGHT':
|
|
46
|
-
|
|
99
|
+
if (activePane !== 1)
|
|
100
|
+
return { ...state, activePane: activePane + 1 };
|
|
101
|
+
return state;
|
|
47
102
|
|
|
48
103
|
case 'MOVE_LEFT':
|
|
49
|
-
|
|
104
|
+
if (activePane !== 0)
|
|
105
|
+
return { ...state, activePane: activePane - 1 };
|
|
106
|
+
return state;
|
|
50
107
|
|
|
51
108
|
case 'MOVE_UP':
|
|
52
109
|
if (activePane === 0) {
|
|
@@ -97,7 +154,6 @@ function getRuleStatus(ruleName, category, config) {
|
|
|
97
154
|
if (config.rules) {
|
|
98
155
|
let val = config.rules[ruleName];
|
|
99
156
|
|
|
100
|
-
// Ignore the prefix to match format in --rules
|
|
101
157
|
if (val === undefined) {
|
|
102
158
|
const foundKey = Object.keys(config.rules).find(key => key.endsWith(`/${ruleName}`));
|
|
103
159
|
if (foundKey) {
|
|
@@ -124,73 +180,56 @@ function stripJsonComments(json) {
|
|
|
124
180
|
function loadRules() {
|
|
125
181
|
let rulesData;
|
|
126
182
|
let config = { rules: {}, categories: {} };
|
|
183
|
+
let configPath = null;
|
|
127
184
|
|
|
128
185
|
try {
|
|
129
|
-
const raw = execSync(
|
|
186
|
+
const raw = execSync(`npx --yes oxlint@${OXLINT_VERSION} --rules --format=json`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
130
187
|
rulesData = JSON.parse(raw);
|
|
131
188
|
} catch (e) {
|
|
132
|
-
console.error(`${COLORS.error}Error: Could not run 'npx oxlint'
|
|
189
|
+
console.error(`${COLORS.error}Error: Could not run 'npx oxlint'.${COLORS.reset}`);
|
|
133
190
|
exit(1);
|
|
134
191
|
}
|
|
135
192
|
|
|
136
193
|
const userConfigPath = argv[2];
|
|
137
|
-
let configPathToLoad = null;
|
|
138
|
-
|
|
139
194
|
if (userConfigPath) {
|
|
140
195
|
if (!fs.existsSync(userConfigPath)) {
|
|
141
196
|
console.error(`${COLORS.error}Error: Config file '${userConfigPath}' not found.${COLORS.reset}`);
|
|
142
197
|
exit(1);
|
|
143
198
|
}
|
|
144
|
-
|
|
199
|
+
configPath = userConfigPath;
|
|
145
200
|
} else if (fs.existsSync('.oxlintrc.json')) {
|
|
146
|
-
|
|
201
|
+
configPath = '.oxlintrc.json';
|
|
147
202
|
}
|
|
148
203
|
|
|
149
|
-
if (
|
|
204
|
+
if (configPath) {
|
|
150
205
|
try {
|
|
151
|
-
const configFile = fs.readFileSync(
|
|
206
|
+
const configFile = fs.readFileSync(configPath, 'utf8');
|
|
152
207
|
const cleanConfig = stripJsonComments(configFile);
|
|
153
208
|
config = JSON.parse(cleanConfig);
|
|
154
|
-
|
|
155
209
|
} catch (e) {
|
|
156
|
-
console.error(`${COLORS.error}Error: Failed to parse '${
|
|
157
|
-
console.error(`${COLORS.warn}${e.message}${COLORS.reset}`);
|
|
210
|
+
console.error(`${COLORS.error}Error: Failed to parse '${configPath}'.${COLORS.reset}`);
|
|
158
211
|
exit(1);
|
|
159
212
|
}
|
|
160
213
|
}
|
|
161
214
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const status = getRuleStatus(rule.value, cat, config);
|
|
170
|
-
|
|
171
|
-
map[cat].push({
|
|
172
|
-
...rule,
|
|
173
|
-
configStatus: status,
|
|
174
|
-
isActive: status === 'error' || status === 'warn'
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const categories = Object.keys(map).sort();
|
|
215
|
+
const map = {};
|
|
216
|
+
rulesData.forEach(rule => {
|
|
217
|
+
const cat = rule.category || 'Uncategorized';
|
|
218
|
+
if (!map[cat]) map[cat] = [];
|
|
219
|
+
const status = getRuleStatus(rule.value, cat, config);
|
|
220
|
+
map[cat].push({ ...rule, configStatus: status, isActive: status === 'error' || status === 'warn' });
|
|
221
|
+
});
|
|
179
222
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
223
|
+
const categories = Object.keys(map).sort();
|
|
224
|
+
categories.forEach(c => {
|
|
225
|
+
map[c].sort((a, b) => {
|
|
226
|
+
if (a.isActive && !b.isActive) return -1;
|
|
227
|
+
if (!a.isActive && b.isActive) return 1;
|
|
228
|
+
return a.value.localeCompare(b.value);
|
|
186
229
|
});
|
|
230
|
+
});
|
|
187
231
|
|
|
188
|
-
|
|
189
|
-
} catch (e) {
|
|
190
|
-
console.error(`${COLORS.error}Error: Something went wrong processing rules.${COLORS.reset}`);
|
|
191
|
-
console.error(e);
|
|
192
|
-
exit(1);
|
|
193
|
-
}
|
|
232
|
+
return { categories, rulesByCategory: map, config, configPath };
|
|
194
233
|
}
|
|
195
234
|
|
|
196
235
|
function updateScroll(idx, currentScroll, viewHeight) {
|
|
@@ -221,7 +260,6 @@ const exitAltScreen = () => write('\x1b[?1049l\x1b[?25h');
|
|
|
221
260
|
function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollOffset, isActive) {
|
|
222
261
|
const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
|
|
223
262
|
const titleClean = title.length > width - 6 ? title.substring(0, width - 7) + '…' : title;
|
|
224
|
-
|
|
225
263
|
const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(width + borderColor.length - 1, '─');
|
|
226
264
|
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
227
265
|
|
|
@@ -231,23 +269,18 @@ function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollO
|
|
|
231
269
|
buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
|
|
232
270
|
|
|
233
271
|
const innerHeight = height - 2;
|
|
234
|
-
|
|
235
272
|
items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
|
|
236
273
|
const absIdx = scrollOffset + i;
|
|
237
274
|
const rawText = (item.value || item).toString();
|
|
238
275
|
let display = rawText.length > width - 4 ? rawText.substring(0, width - 5) + '…' : rawText.padEnd(width - 4);
|
|
239
|
-
|
|
240
276
|
let itemColor = COLORS.dim;
|
|
241
277
|
if (item.configStatus === 'error') itemColor = COLORS.error;
|
|
242
278
|
else if (item.configStatus === 'warn') itemColor = COLORS.warn;
|
|
243
279
|
else if (item.isActive) itemColor = COLORS.success;
|
|
244
280
|
|
|
245
281
|
buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
|
|
246
|
-
|
|
247
282
|
if (absIdx === selectedIdx) {
|
|
248
|
-
buffer.push(isActive
|
|
249
|
-
? `${COLORS.selectedBg}${display}${COLORS.reset}`
|
|
250
|
-
: `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`);
|
|
283
|
+
buffer.push(isActive ? `${COLORS.selectedBg}${display}${COLORS.reset}` : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`);
|
|
251
284
|
} else {
|
|
252
285
|
buffer.push(`${itemColor}${display}${COLORS.reset}`);
|
|
253
286
|
}
|
|
@@ -256,10 +289,8 @@ function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollO
|
|
|
256
289
|
|
|
257
290
|
function drawStats(buffer, x, y, width, height, rules) {
|
|
258
291
|
const borderColor = COLORS.borderInactive;
|
|
259
|
-
|
|
260
292
|
const topBorder = `${borderColor}┌─ STATS `.padEnd(width + borderColor.length - 1, '─');
|
|
261
293
|
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
262
|
-
|
|
263
294
|
for (let i = 1; i < height - 1; i++) buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
|
|
264
295
|
buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
|
|
265
296
|
|
|
@@ -287,10 +318,8 @@ function drawStats(buffer, x, y, width, height, rules) {
|
|
|
287
318
|
|
|
288
319
|
function drawDetails(buffer, x, y, width, height, rule, isActive) {
|
|
289
320
|
const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
|
|
290
|
-
|
|
291
321
|
const topBorder = `${borderColor}┌─ DETAILS `.padEnd(width + borderColor.length - 1, '─');
|
|
292
322
|
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
293
|
-
|
|
294
323
|
for (let i = 1; i < height - 1; i++) buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
|
|
295
324
|
buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
|
|
296
325
|
|
|
@@ -314,18 +343,15 @@ function drawDetails(buffer, x, y, width, height, rule, isActive) {
|
|
|
314
343
|
|
|
315
344
|
let line = 0;
|
|
316
345
|
labels.forEach(([lbl, val]) => {
|
|
317
|
-
if (lbl === 'Status') {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
line++;
|
|
321
|
-
}
|
|
346
|
+
if (lbl === 'Status' && line < height - 2) {
|
|
347
|
+
buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`);
|
|
348
|
+
line++;
|
|
322
349
|
return;
|
|
323
350
|
}
|
|
324
|
-
|
|
325
351
|
const chunks = chunkString(String(val || 'N/A'), width - 15);
|
|
326
|
-
chunks.forEach((chunk
|
|
352
|
+
chunks.forEach((chunk) => {
|
|
327
353
|
if (line < height - 2) {
|
|
328
|
-
buffer.push(`\x1b[${y + 1 + line};${x + 2}H${
|
|
354
|
+
buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`);
|
|
329
355
|
line++;
|
|
330
356
|
}
|
|
331
357
|
});
|
|
@@ -337,23 +363,21 @@ function render() {
|
|
|
337
363
|
const currentCat = state.categories[state.selectedCatIdx];
|
|
338
364
|
const rules = state.rulesByCategory[currentCat] || [];
|
|
339
365
|
const rule = rules[state.selectedRuleIdx];
|
|
340
|
-
|
|
341
366
|
const boxHeight = rows - 4;
|
|
342
|
-
|
|
343
367
|
const col1W = Math.floor(columns * 0.2);
|
|
344
368
|
const col2W = Math.floor(columns * 0.3);
|
|
345
369
|
const col3W = columns - col1W - col2W - 2;
|
|
346
|
-
|
|
347
370
|
const statsHeight = 6;
|
|
348
371
|
const catListHeight = boxHeight - statsHeight;
|
|
349
372
|
|
|
350
373
|
const buffer = ['\x1b[H\x1b[J'];
|
|
351
|
-
|
|
352
374
|
drawBox(buffer, 1, 1, col1W, catListHeight, 'CATEGORIES', state.categories, state.selectedCatIdx, state.scrollCat, state.activePane === 0);
|
|
353
375
|
drawStats(buffer, 1, 1 + catListHeight, col1W, statsHeight, rules);
|
|
354
376
|
drawBox(buffer, col1W + 1, 1, col2W, boxHeight, `RULES (${rules.length})`, rules, state.selectedRuleIdx, state.scrollRule, state.activePane === 1);
|
|
355
377
|
drawDetails(buffer, col1W + col2W + 1, 1, col3W, boxHeight, rule, state.activePane === 2);
|
|
356
|
-
|
|
378
|
+
|
|
379
|
+
const footerConfig = state.configPath ? `Config: ${state.configPath}` : 'No config loaded';
|
|
380
|
+
buffer.push(`\x1b[${rows - 2};2H${COLORS.dim}Nav: Arrows/HJKL | 1:Off 2:Warn 3:Err | Enter: Docs | Q: Quit | ${footerConfig}${COLORS.reset}`);
|
|
357
381
|
write(buffer.join(''));
|
|
358
382
|
}
|
|
359
383
|
|
|
@@ -370,7 +394,7 @@ readline.emitKeypressEvents(stdin);
|
|
|
370
394
|
if (stdin.isTTY) stdin.setRawMode(true);
|
|
371
395
|
|
|
372
396
|
stdin.on('keypress', (_, key) => {
|
|
373
|
-
const action = KEY_MAP[key.name] || (key.ctrl && key.name === 'c' ? { type: 'EXIT' } : null);
|
|
397
|
+
const action = KEY_MAP[key.name] || (key.ctrl && key.name === 'c' ? { type: 'EXIT' } : (KEY_MAP[key.sequence] || null));
|
|
374
398
|
if (!action) return;
|
|
375
399
|
|
|
376
400
|
if (action.type === 'EXIT') {
|
|
@@ -378,7 +402,7 @@ stdin.on('keypress', (_, key) => {
|
|
|
378
402
|
exit(0);
|
|
379
403
|
}
|
|
380
404
|
|
|
381
|
-
if (action.type === 'OPEN_DOCS') {
|
|
405
|
+
if (action.type === 'OPEN_DOCS' && state.activePane === 1) {
|
|
382
406
|
const currentCat = state.categories[state.selectedCatIdx];
|
|
383
407
|
const rule = state.rulesByCategory[currentCat]?.[state.selectedRuleIdx];
|
|
384
408
|
if (rule) openUrl(rule.docs_url || rule.url);
|