oxlint-tui 1.0.6 → 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 -4
- package/package.json +1 -1
- package/tui.js +90 -69
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
|
|
|
@@ -52,7 +54,10 @@ oxlint-tui
|
|
|
52
54
|
| **↑** / **k** | Move selection Up |
|
|
53
55
|
| **↓** / **j** | Move selection Down |
|
|
54
56
|
| **←** / **h** | Move focus Left (Categories <-> Rules) |
|
|
55
|
-
| **→** / **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" |
|
|
56
61
|
| **Enter** | Open Rule Documentation in Browser |
|
|
57
62
|
| **q** / **Esc** | Quit |
|
|
58
63
|
|
|
@@ -63,7 +68,7 @@ oxlint-tui
|
|
|
63
68
|
|
|
64
69
|
## Roadmap
|
|
65
70
|
|
|
66
|
-
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.
|
|
67
72
|
|
|
68
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).
|
|
69
74
|
|
package/package.json
CHANGED
package/tui.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
8
|
const OXLINT_VERSION = "1.41.0"
|
|
@@ -30,10 +30,38 @@ const KEY_MAP = {
|
|
|
30
30
|
'l': { type: 'MOVE_RIGHT' },
|
|
31
31
|
'return': { type: 'OPEN_DOCS' },
|
|
32
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' },
|
|
33
36
|
'q': { type: 'EXIT' },
|
|
34
37
|
'escape': { type: 'EXIT' }
|
|
35
38
|
};
|
|
36
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
|
+
|
|
37
65
|
function reducer(state, action) {
|
|
38
66
|
const { categories, rulesByCategory, selectedCatIdx, selectedRuleIdx, activePane } = state;
|
|
39
67
|
const currentCat = categories[selectedCatIdx];
|
|
@@ -44,12 +72,38 @@ function reducer(state, action) {
|
|
|
44
72
|
const catViewHeight = viewHeight - statsHeight;
|
|
45
73
|
|
|
46
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
|
+
|
|
47
98
|
case 'MOVE_RIGHT':
|
|
48
99
|
if (activePane !== 1)
|
|
49
|
-
return { ...state, activePane:
|
|
100
|
+
return { ...state, activePane: activePane + 1 };
|
|
101
|
+
return state;
|
|
50
102
|
|
|
51
103
|
case 'MOVE_LEFT':
|
|
52
|
-
|
|
104
|
+
if (activePane !== 0)
|
|
105
|
+
return { ...state, activePane: activePane - 1 };
|
|
106
|
+
return state;
|
|
53
107
|
|
|
54
108
|
case 'MOVE_UP':
|
|
55
109
|
if (activePane === 0) {
|
|
@@ -100,7 +154,6 @@ function getRuleStatus(ruleName, category, config) {
|
|
|
100
154
|
if (config.rules) {
|
|
101
155
|
let val = config.rules[ruleName];
|
|
102
156
|
|
|
103
|
-
// Ignore the prefix to match format in --rules
|
|
104
157
|
if (val === undefined) {
|
|
105
158
|
const foundKey = Object.keys(config.rules).find(key => key.endsWith(`/${ruleName}`));
|
|
106
159
|
if (foundKey) {
|
|
@@ -127,73 +180,56 @@ function stripJsonComments(json) {
|
|
|
127
180
|
function loadRules() {
|
|
128
181
|
let rulesData;
|
|
129
182
|
let config = { rules: {}, categories: {} };
|
|
183
|
+
let configPath = null;
|
|
130
184
|
|
|
131
185
|
try {
|
|
132
186
|
const raw = execSync(`npx --yes oxlint@${OXLINT_VERSION} --rules --format=json`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
133
187
|
rulesData = JSON.parse(raw);
|
|
134
188
|
} catch (e) {
|
|
135
|
-
console.error(`${COLORS.error}Error: Could not run 'npx oxlint'
|
|
189
|
+
console.error(`${COLORS.error}Error: Could not run 'npx oxlint'.${COLORS.reset}`);
|
|
136
190
|
exit(1);
|
|
137
191
|
}
|
|
138
192
|
|
|
139
193
|
const userConfigPath = argv[2];
|
|
140
|
-
let configPathToLoad = null;
|
|
141
|
-
|
|
142
194
|
if (userConfigPath) {
|
|
143
195
|
if (!fs.existsSync(userConfigPath)) {
|
|
144
196
|
console.error(`${COLORS.error}Error: Config file '${userConfigPath}' not found.${COLORS.reset}`);
|
|
145
197
|
exit(1);
|
|
146
198
|
}
|
|
147
|
-
|
|
199
|
+
configPath = userConfigPath;
|
|
148
200
|
} else if (fs.existsSync('.oxlintrc.json')) {
|
|
149
|
-
|
|
201
|
+
configPath = '.oxlintrc.json';
|
|
150
202
|
}
|
|
151
203
|
|
|
152
|
-
if (
|
|
204
|
+
if (configPath) {
|
|
153
205
|
try {
|
|
154
|
-
const configFile = fs.readFileSync(
|
|
206
|
+
const configFile = fs.readFileSync(configPath, 'utf8');
|
|
155
207
|
const cleanConfig = stripJsonComments(configFile);
|
|
156
208
|
config = JSON.parse(cleanConfig);
|
|
157
|
-
|
|
158
209
|
} catch (e) {
|
|
159
|
-
console.error(`${COLORS.error}Error: Failed to parse '${
|
|
160
|
-
console.error(`${COLORS.warn}${e.message}${COLORS.reset}`);
|
|
210
|
+
console.error(`${COLORS.error}Error: Failed to parse '${configPath}'.${COLORS.reset}`);
|
|
161
211
|
exit(1);
|
|
162
212
|
}
|
|
163
213
|
}
|
|
164
214
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const status = getRuleStatus(rule.value, cat, config);
|
|
173
|
-
|
|
174
|
-
map[cat].push({
|
|
175
|
-
...rule,
|
|
176
|
-
configStatus: status,
|
|
177
|
-
isActive: status === 'error' || status === 'warn'
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
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
|
+
});
|
|
182
222
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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);
|
|
189
229
|
});
|
|
230
|
+
});
|
|
190
231
|
|
|
191
|
-
|
|
192
|
-
} catch (e) {
|
|
193
|
-
console.error(`${COLORS.error}Error: Something went wrong processing rules.${COLORS.reset}`);
|
|
194
|
-
console.error(e);
|
|
195
|
-
exit(1);
|
|
196
|
-
}
|
|
232
|
+
return { categories, rulesByCategory: map, config, configPath };
|
|
197
233
|
}
|
|
198
234
|
|
|
199
235
|
function updateScroll(idx, currentScroll, viewHeight) {
|
|
@@ -224,7 +260,6 @@ const exitAltScreen = () => write('\x1b[?1049l\x1b[?25h');
|
|
|
224
260
|
function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollOffset, isActive) {
|
|
225
261
|
const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
|
|
226
262
|
const titleClean = title.length > width - 6 ? title.substring(0, width - 7) + '…' : title;
|
|
227
|
-
|
|
228
263
|
const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(width + borderColor.length - 1, '─');
|
|
229
264
|
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
230
265
|
|
|
@@ -234,23 +269,18 @@ function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollO
|
|
|
234
269
|
buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
|
|
235
270
|
|
|
236
271
|
const innerHeight = height - 2;
|
|
237
|
-
|
|
238
272
|
items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
|
|
239
273
|
const absIdx = scrollOffset + i;
|
|
240
274
|
const rawText = (item.value || item).toString();
|
|
241
275
|
let display = rawText.length > width - 4 ? rawText.substring(0, width - 5) + '…' : rawText.padEnd(width - 4);
|
|
242
|
-
|
|
243
276
|
let itemColor = COLORS.dim;
|
|
244
277
|
if (item.configStatus === 'error') itemColor = COLORS.error;
|
|
245
278
|
else if (item.configStatus === 'warn') itemColor = COLORS.warn;
|
|
246
279
|
else if (item.isActive) itemColor = COLORS.success;
|
|
247
280
|
|
|
248
281
|
buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
|
|
249
|
-
|
|
250
282
|
if (absIdx === selectedIdx) {
|
|
251
|
-
buffer.push(isActive
|
|
252
|
-
? `${COLORS.selectedBg}${display}${COLORS.reset}`
|
|
253
|
-
: `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`);
|
|
283
|
+
buffer.push(isActive ? `${COLORS.selectedBg}${display}${COLORS.reset}` : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`);
|
|
254
284
|
} else {
|
|
255
285
|
buffer.push(`${itemColor}${display}${COLORS.reset}`);
|
|
256
286
|
}
|
|
@@ -259,10 +289,8 @@ function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollO
|
|
|
259
289
|
|
|
260
290
|
function drawStats(buffer, x, y, width, height, rules) {
|
|
261
291
|
const borderColor = COLORS.borderInactive;
|
|
262
|
-
|
|
263
292
|
const topBorder = `${borderColor}┌─ STATS `.padEnd(width + borderColor.length - 1, '─');
|
|
264
293
|
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
265
|
-
|
|
266
294
|
for (let i = 1; i < height - 1; i++) buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
|
|
267
295
|
buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
|
|
268
296
|
|
|
@@ -290,10 +318,8 @@ function drawStats(buffer, x, y, width, height, rules) {
|
|
|
290
318
|
|
|
291
319
|
function drawDetails(buffer, x, y, width, height, rule, isActive) {
|
|
292
320
|
const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
|
|
293
|
-
|
|
294
321
|
const topBorder = `${borderColor}┌─ DETAILS `.padEnd(width + borderColor.length - 1, '─');
|
|
295
322
|
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
296
|
-
|
|
297
323
|
for (let i = 1; i < height - 1; i++) buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
|
|
298
324
|
buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
|
|
299
325
|
|
|
@@ -317,18 +343,15 @@ function drawDetails(buffer, x, y, width, height, rule, isActive) {
|
|
|
317
343
|
|
|
318
344
|
let line = 0;
|
|
319
345
|
labels.forEach(([lbl, val]) => {
|
|
320
|
-
if (lbl === 'Status') {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
line++;
|
|
324
|
-
}
|
|
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++;
|
|
325
349
|
return;
|
|
326
350
|
}
|
|
327
|
-
|
|
328
351
|
const chunks = chunkString(String(val || 'N/A'), width - 15);
|
|
329
|
-
chunks.forEach((chunk
|
|
352
|
+
chunks.forEach((chunk) => {
|
|
330
353
|
if (line < height - 2) {
|
|
331
|
-
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}`);
|
|
332
355
|
line++;
|
|
333
356
|
}
|
|
334
357
|
});
|
|
@@ -340,23 +363,21 @@ function render() {
|
|
|
340
363
|
const currentCat = state.categories[state.selectedCatIdx];
|
|
341
364
|
const rules = state.rulesByCategory[currentCat] || [];
|
|
342
365
|
const rule = rules[state.selectedRuleIdx];
|
|
343
|
-
|
|
344
366
|
const boxHeight = rows - 4;
|
|
345
|
-
|
|
346
367
|
const col1W = Math.floor(columns * 0.2);
|
|
347
368
|
const col2W = Math.floor(columns * 0.3);
|
|
348
369
|
const col3W = columns - col1W - col2W - 2;
|
|
349
|
-
|
|
350
370
|
const statsHeight = 6;
|
|
351
371
|
const catListHeight = boxHeight - statsHeight;
|
|
352
372
|
|
|
353
373
|
const buffer = ['\x1b[H\x1b[J'];
|
|
354
|
-
|
|
355
374
|
drawBox(buffer, 1, 1, col1W, catListHeight, 'CATEGORIES', state.categories, state.selectedCatIdx, state.scrollCat, state.activePane === 0);
|
|
356
375
|
drawStats(buffer, 1, 1 + catListHeight, col1W, statsHeight, rules);
|
|
357
376
|
drawBox(buffer, col1W + 1, 1, col2W, boxHeight, `RULES (${rules.length})`, rules, state.selectedRuleIdx, state.scrollRule, state.activePane === 1);
|
|
358
377
|
drawDetails(buffer, col1W + col2W + 1, 1, col3W, boxHeight, rule, state.activePane === 2);
|
|
359
|
-
|
|
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}`);
|
|
360
381
|
write(buffer.join(''));
|
|
361
382
|
}
|
|
362
383
|
|
|
@@ -373,7 +394,7 @@ readline.emitKeypressEvents(stdin);
|
|
|
373
394
|
if (stdin.isTTY) stdin.setRawMode(true);
|
|
374
395
|
|
|
375
396
|
stdin.on('keypress', (_, key) => {
|
|
376
|
-
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));
|
|
377
398
|
if (!action) return;
|
|
378
399
|
|
|
379
400
|
if (action.type === 'EXIT') {
|
|
@@ -381,7 +402,7 @@ stdin.on('keypress', (_, key) => {
|
|
|
381
402
|
exit(0);
|
|
382
403
|
}
|
|
383
404
|
|
|
384
|
-
if (action.type === 'OPEN_DOCS') {
|
|
405
|
+
if (action.type === 'OPEN_DOCS' && state.activePane === 1) {
|
|
385
406
|
const currentCat = state.categories[state.selectedCatIdx];
|
|
386
407
|
const rule = state.rulesByCategory[currentCat]?.[state.selectedRuleIdx];
|
|
387
408
|
if (rule) openUrl(rule.docs_url || rule.url);
|