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.
Files changed (3) hide show
  1. package/README.md +9 -4
  2. package/package.json +1 -1
  3. 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
  ![screenshot](https://raw.githubusercontent.com/holoflash/oxlint-tui/refs/heads/main/screenshot.png)
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 create 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-tui",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "A Node TUI Oxlint rules and configuration browser",
5
5
  "type": "module",
6
6
  "bin": {
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 'node:readline';
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: Math.min(2, activePane + 1) };
100
+ return { ...state, activePane: activePane + 1 };
101
+ return state;
50
102
 
51
103
  case 'MOVE_LEFT':
52
- return { ...state, activePane: Math.max(0, activePane - 1) };
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'. Ensure oxlint is installed.${COLORS.reset}`);
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
- configPathToLoad = userConfigPath;
199
+ configPath = userConfigPath;
148
200
  } else if (fs.existsSync('.oxlintrc.json')) {
149
- configPathToLoad = '.oxlintrc.json';
201
+ configPath = '.oxlintrc.json';
150
202
  }
151
203
 
152
- if (configPathToLoad) {
204
+ if (configPath) {
153
205
  try {
154
- const configFile = fs.readFileSync(configPathToLoad, 'utf8');
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 '${configPathToLoad}'.${COLORS.reset}`);
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
- try {
166
- const map = {};
167
-
168
- rulesData.forEach(rule => {
169
- const cat = rule.category || 'Uncategorized';
170
- if (!map[cat]) map[cat] = [];
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
- categories.forEach(c => {
184
- map[c].sort((a, b) => {
185
- if (a.isActive && !b.isActive) return -1;
186
- if (!a.isActive && b.isActive) return 1;
187
- return a.value.localeCompare(b.value);
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
- return { categories, rulesByCategory: map };
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
- if (line < height - 2) {
322
- buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`);
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, i) => {
352
+ chunks.forEach((chunk) => {
330
353
  if (line < height - 2) {
331
- buffer.push(`\x1b[${y + 1 + line};${x + 2}H${i === 0 ? COLORS.highlight + lbl.padEnd(10) : ' '.repeat(10)} ${COLORS.reset}${chunk}`);
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
- buffer.push(`\x1b[${rows - 2};2H${COLORS.dim}Nav: Arrows/HJKL | Enter: Docs | Q: Quit${COLORS.reset}`);
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);