oxlint-tui 1.0.6 → 1.0.8
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 +13 -4
- package/package.json +4 -1
- package/tui.js +375 -202
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
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 and the number of warnings/errors they produce when toggled ON.
|
|
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
|
+
Pressing <kbd>r</kbd> will lint the project using "oxlint". Pressing <kbd>t</kbd> will lint include the oxlint-tsgolint package and lint the project using "oxlint --type-aware". The results are presented directly in the interface.
|
|
8
|
+
|
|
9
|
+
**NOTE**: At the moment, comments will be erased from your configuration file when adding or toggling rules.
|
|
6
10
|
|
|
7
11
|

|
|
8
12
|
|
|
@@ -52,7 +56,12 @@ oxlint-tui
|
|
|
52
56
|
| **↑** / **k** | Move selection Up |
|
|
53
57
|
| **↓** / **j** | Move selection Down |
|
|
54
58
|
| **←** / **h** | Move focus Left (Categories <-> Rules) |
|
|
55
|
-
| **→** / **l** | Move focus Right (Categories <-> Rules)
|
|
59
|
+
| **→** / **l** | Move focus Right (Categories <-> Rules) ||
|
|
60
|
+
| **1** | Set selected rule to "off" |
|
|
61
|
+
| **2** | Set selected rule to "warn" |
|
|
62
|
+
| **3** | Set selected rule to "error" |
|
|
63
|
+
| **r** | Run the linter with "oxlint" |
|
|
64
|
+
| **t** | Run the linter with "-p oxlint-tsgolint@latest oxlint --type-aware" |
|
|
56
65
|
| **Enter** | Open Rule Documentation in Browser |
|
|
57
66
|
| **q** / **Esc** | Quit |
|
|
58
67
|
|
|
@@ -63,7 +72,7 @@ oxlint-tui
|
|
|
63
72
|
|
|
64
73
|
## Roadmap
|
|
65
74
|
|
|
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
|
|
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 - 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
76
|
|
|
68
77
|
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
78
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxlint-tui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "A Node TUI Oxlint rules and configuration browser",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
"tui",
|
|
20
20
|
"cli"
|
|
21
21
|
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"oxlint-tsgolint": "0.11.1"
|
|
24
|
+
},
|
|
22
25
|
"author": "holoflash",
|
|
23
26
|
"license": "MIT",
|
|
24
27
|
"engines": {
|
package/tui.js
CHANGED
|
@@ -1,96 +1,208 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { execSync, exec } from
|
|
4
|
-
import { stdout, stdin, exit, platform, argv } from
|
|
5
|
-
import readline from
|
|
6
|
-
import fs from
|
|
3
|
+
import { execSync, exec } 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
7
|
|
|
8
|
-
const OXLINT_VERSION = "1.41.0"
|
|
8
|
+
const OXLINT_VERSION = "1.41.0";
|
|
9
9
|
|
|
10
10
|
const COLORS = {
|
|
11
|
-
reset:
|
|
12
|
-
dim:
|
|
13
|
-
highlight:
|
|
14
|
-
selectedBg:
|
|
15
|
-
borderActive:
|
|
16
|
-
borderInactive:
|
|
17
|
-
error:
|
|
18
|
-
warn:
|
|
19
|
-
success:
|
|
11
|
+
reset: "\x1b[0m",
|
|
12
|
+
dim: "\x1b[90m",
|
|
13
|
+
highlight: "\x1b[38;5;110m",
|
|
14
|
+
selectedBg: "\x1b[47m\x1b[30m",
|
|
15
|
+
borderActive: "\x1b[36m",
|
|
16
|
+
borderInactive: "\x1b[90m",
|
|
17
|
+
error: "\x1b[31m",
|
|
18
|
+
warn: "\x1b[33m",
|
|
19
|
+
success: "\x1b[32m",
|
|
20
|
+
info: "\x1b[34m",
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
const KEY_MAP = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
k: { type: "MOVE_UP" },
|
|
25
|
+
up: { type: "MOVE_UP" },
|
|
26
|
+
down: { type: "MOVE_DOWN" },
|
|
27
|
+
j: { type: "MOVE_DOWN" },
|
|
28
|
+
left: { type: "MOVE_LEFT" },
|
|
29
|
+
h: { type: "MOVE_LEFT" },
|
|
30
|
+
right: { type: "MOVE_RIGHT" },
|
|
31
|
+
l: { type: "MOVE_RIGHT" },
|
|
32
|
+
return: { type: "OPEN_DOCS" },
|
|
33
|
+
enter: { type: "OPEN_DOCS" },
|
|
34
|
+
1: { type: "SET_STATUS", value: "off" },
|
|
35
|
+
2: { type: "SET_STATUS", value: "warn" },
|
|
36
|
+
3: { type: "SET_STATUS", value: "error" },
|
|
37
|
+
q: { type: "EXIT" },
|
|
38
|
+
r: { type: "RUN_LINT" },
|
|
39
|
+
t: { type: "RUN_TYPE_AWARE_LINT" },
|
|
40
|
+
escape: { type: "EXIT" },
|
|
35
41
|
};
|
|
36
42
|
|
|
43
|
+
let state = {
|
|
44
|
+
activePane: 0,
|
|
45
|
+
selectedCatIdx: 0,
|
|
46
|
+
selectedRuleIdx: 0,
|
|
47
|
+
scrollCat: 0,
|
|
48
|
+
scrollRule: 0,
|
|
49
|
+
isLinting: false,
|
|
50
|
+
message: "Press R to lint; T to lint using --type-aware",
|
|
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(type_aware) {
|
|
85
|
+
if (state.isLinting) return;
|
|
86
|
+
|
|
87
|
+
state.isLinting = true;
|
|
88
|
+
state.message = type_aware
|
|
89
|
+
? "Running linter with --type-aware..."
|
|
90
|
+
: "Running linter...";
|
|
91
|
+
state.messageType = "info";
|
|
92
|
+
render();
|
|
93
|
+
|
|
94
|
+
const cmd = `npx -p oxlint@${OXLINT_VERSION} ${type_aware ? "-p oxlint-tsgolint@latest oxlint --type-aware" : "oxlint"}`;
|
|
95
|
+
|
|
96
|
+
exec(cmd, (error, stdout, stderr) => {
|
|
97
|
+
const fullOutput = stdout + stderr;
|
|
98
|
+
const summaryMatch = fullOutput.match(
|
|
99
|
+
/Found (\d+) warnings? and (\d+) errors?/,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
state.isLinting = false;
|
|
103
|
+
if (summaryMatch) {
|
|
104
|
+
state.message = summaryMatch[0];
|
|
105
|
+
const errors = parseInt(summaryMatch[2]);
|
|
106
|
+
state.messageType = errors > 0 ? "error" : "warn";
|
|
107
|
+
} else if (
|
|
108
|
+
fullOutput.includes("Finished") ||
|
|
109
|
+
(!error && fullOutput.length === 0)
|
|
110
|
+
) {
|
|
111
|
+
state.message = "Linting passed! 0 issues found.";
|
|
112
|
+
state.messageType = "success";
|
|
113
|
+
} else {
|
|
114
|
+
state.message = "Something went wrong";
|
|
115
|
+
state.messageType = "error";
|
|
116
|
+
}
|
|
117
|
+
render();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
37
121
|
function reducer(state, action) {
|
|
38
|
-
const {
|
|
122
|
+
const {
|
|
123
|
+
categories,
|
|
124
|
+
rulesByCategory,
|
|
125
|
+
selectedCatIdx,
|
|
126
|
+
selectedRuleIdx,
|
|
127
|
+
activePane,
|
|
128
|
+
} = state;
|
|
39
129
|
const currentCat = categories[selectedCatIdx];
|
|
40
130
|
const currentRules = rulesByCategory[currentCat] || [];
|
|
41
|
-
const viewHeight = stdout.rows -
|
|
42
|
-
|
|
131
|
+
const viewHeight = stdout.rows - 8;
|
|
43
132
|
const statsHeight = 7;
|
|
44
133
|
const catViewHeight = viewHeight - statsHeight;
|
|
45
134
|
|
|
46
135
|
switch (action.type) {
|
|
47
|
-
case
|
|
48
|
-
if (activePane !== 1)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
136
|
+
case "SET_STATUS": {
|
|
137
|
+
if (activePane !== 1) return state;
|
|
138
|
+
const rule = currentRules[selectedRuleIdx];
|
|
139
|
+
if (!rule) return state;
|
|
140
|
+
updateConfig(rule, action.value);
|
|
141
|
+
const updatedRules = [...currentRules];
|
|
142
|
+
updatedRules[selectedRuleIdx] = {
|
|
143
|
+
...rule,
|
|
144
|
+
configStatus: action.value,
|
|
145
|
+
isActive: action.value === "error" || action.value === "warn",
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
...state,
|
|
149
|
+
message: `Rule '${rule.value}' set to: ${action.value}`,
|
|
150
|
+
messageType: "info",
|
|
151
|
+
rulesByCategory: {
|
|
152
|
+
...rulesByCategory,
|
|
153
|
+
[currentCat]: updatedRules,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
case "MOVE_RIGHT":
|
|
158
|
+
if (activePane !== 1) return { ...state, activePane: activePane + 1 };
|
|
159
|
+
return state;
|
|
53
160
|
|
|
54
|
-
case
|
|
161
|
+
case "MOVE_LEFT":
|
|
162
|
+
if (activePane !== 0) return { ...state, activePane: activePane - 1 };
|
|
163
|
+
return state;
|
|
164
|
+
case "MOVE_UP":
|
|
55
165
|
if (activePane === 0) {
|
|
56
|
-
const nextIdx =
|
|
166
|
+
const nextIdx =
|
|
167
|
+
selectedCatIdx === 0 ? categories.length - 1 : selectedCatIdx - 1;
|
|
57
168
|
return {
|
|
58
169
|
...state,
|
|
59
170
|
selectedCatIdx: nextIdx,
|
|
60
171
|
selectedRuleIdx: 0,
|
|
61
172
|
scrollRule: 0,
|
|
62
|
-
scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight)
|
|
173
|
+
scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
|
|
63
174
|
};
|
|
64
175
|
} else if (activePane === 1) {
|
|
65
|
-
const nextIdx =
|
|
176
|
+
const nextIdx =
|
|
177
|
+
selectedRuleIdx === 0 ? currentRules.length - 1 : selectedRuleIdx - 1;
|
|
66
178
|
return {
|
|
67
179
|
...state,
|
|
68
180
|
selectedRuleIdx: nextIdx,
|
|
69
|
-
scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight)
|
|
181
|
+
scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
|
|
70
182
|
};
|
|
71
183
|
}
|
|
72
184
|
return state;
|
|
73
|
-
|
|
74
|
-
case 'MOVE_DOWN':
|
|
185
|
+
case "MOVE_DOWN":
|
|
75
186
|
if (activePane === 0) {
|
|
76
|
-
const nextIdx =
|
|
187
|
+
const nextIdx =
|
|
188
|
+
selectedCatIdx === categories.length - 1 ? 0 : selectedCatIdx + 1;
|
|
77
189
|
return {
|
|
78
190
|
...state,
|
|
79
191
|
selectedCatIdx: nextIdx,
|
|
80
192
|
selectedRuleIdx: 0,
|
|
81
193
|
scrollRule: 0,
|
|
82
|
-
scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight)
|
|
194
|
+
scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
|
|
83
195
|
};
|
|
84
196
|
} else if (activePane === 1) {
|
|
85
|
-
const nextIdx =
|
|
197
|
+
const nextIdx =
|
|
198
|
+
selectedRuleIdx === currentRules.length - 1 ? 0 : selectedRuleIdx + 1;
|
|
86
199
|
return {
|
|
87
200
|
...state,
|
|
88
201
|
selectedRuleIdx: nextIdx,
|
|
89
|
-
scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight)
|
|
202
|
+
scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
|
|
90
203
|
};
|
|
91
204
|
}
|
|
92
205
|
return state;
|
|
93
|
-
|
|
94
206
|
default:
|
|
95
207
|
return state;
|
|
96
208
|
}
|
|
@@ -99,101 +211,82 @@ function reducer(state, action) {
|
|
|
99
211
|
function getRuleStatus(ruleName, category, config) {
|
|
100
212
|
if (config.rules) {
|
|
101
213
|
let val = config.rules[ruleName];
|
|
102
|
-
|
|
103
|
-
// Ignore the prefix to match format in --rules
|
|
104
214
|
if (val === undefined) {
|
|
105
|
-
const foundKey = Object.keys(config.rules).find(key =>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (val !== undefined) {
|
|
112
|
-
if (Array.isArray(val)) return val[0];
|
|
113
|
-
return val;
|
|
215
|
+
const foundKey = Object.keys(config.rules).find((key) =>
|
|
216
|
+
key.endsWith(`/${ruleName}`),
|
|
217
|
+
);
|
|
218
|
+
if (foundKey) val = config.rules[foundKey];
|
|
114
219
|
}
|
|
220
|
+
if (val !== undefined) return Array.isArray(val) ? val[0] : val;
|
|
115
221
|
}
|
|
116
|
-
|
|
117
|
-
if (config.categories && config.categories[category]) {
|
|
118
|
-
return config.categories[category];
|
|
119
|
-
}
|
|
120
|
-
return 'off';
|
|
222
|
+
return (config.categories && config.categories[category]) || "off";
|
|
121
223
|
}
|
|
122
224
|
|
|
123
225
|
function stripJsonComments(json) {
|
|
124
|
-
return json.replace(
|
|
226
|
+
return json.replace(
|
|
227
|
+
/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g,
|
|
228
|
+
(m, g) => (g ? "" : m),
|
|
229
|
+
);
|
|
125
230
|
}
|
|
126
231
|
|
|
127
232
|
function loadRules() {
|
|
128
233
|
let rulesData;
|
|
129
|
-
let config = {
|
|
234
|
+
let config = {
|
|
235
|
+
rules: {},
|
|
236
|
+
categories: {},
|
|
237
|
+
};
|
|
238
|
+
let configPath = null;
|
|
130
239
|
|
|
131
240
|
try {
|
|
132
|
-
const raw = execSync(
|
|
241
|
+
const raw = execSync(
|
|
242
|
+
`npx --yes oxlint@${OXLINT_VERSION} --rules --format=json`,
|
|
243
|
+
{
|
|
244
|
+
encoding: "utf8",
|
|
245
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
246
|
+
},
|
|
247
|
+
);
|
|
133
248
|
rulesData = JSON.parse(raw);
|
|
134
249
|
} catch (e) {
|
|
135
|
-
console.error(
|
|
250
|
+
console.error(
|
|
251
|
+
`${COLORS.error}Error: Could not run 'npx oxlint'.${COLORS.reset}`,
|
|
252
|
+
);
|
|
136
253
|
exit(1);
|
|
137
254
|
}
|
|
138
255
|
|
|
139
256
|
const userConfigPath = argv[2];
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
console.error(`${COLORS.error}Error: Config file '${userConfigPath}' not found.${COLORS.reset}`);
|
|
145
|
-
exit(1);
|
|
146
|
-
}
|
|
147
|
-
configPathToLoad = userConfigPath;
|
|
148
|
-
} else if (fs.existsSync('.oxlintrc.json')) {
|
|
149
|
-
configPathToLoad = '.oxlintrc.json';
|
|
257
|
+
if (userConfigPath && fs.existsSync(userConfigPath)) {
|
|
258
|
+
configPath = userConfigPath;
|
|
259
|
+
} else if (fs.existsSync(".oxlintrc.json")) {
|
|
260
|
+
configPath = ".oxlintrc.json";
|
|
150
261
|
}
|
|
151
262
|
|
|
152
|
-
if (
|
|
263
|
+
if (configPath) {
|
|
153
264
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
} catch (e) {
|
|
159
|
-
console.error(`${COLORS.error}Error: Failed to parse '${configPathToLoad}'.${COLORS.reset}`);
|
|
160
|
-
console.error(`${COLORS.warn}${e.message}${COLORS.reset}`);
|
|
161
|
-
exit(1);
|
|
162
|
-
}
|
|
265
|
+
config = JSON.parse(
|
|
266
|
+
stripJsonComments(fs.readFileSync(configPath, "utf8")),
|
|
267
|
+
);
|
|
268
|
+
} catch (e) { }
|
|
163
269
|
}
|
|
164
270
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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();
|
|
182
|
-
|
|
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
|
-
});
|
|
271
|
+
const map = {};
|
|
272
|
+
rulesData.forEach((rule) => {
|
|
273
|
+
const cat = rule.category || "Uncategorized";
|
|
274
|
+
if (!map[cat]) map[cat] = [];
|
|
275
|
+
const status = getRuleStatus(rule.value, cat, config);
|
|
276
|
+
map[cat].push({
|
|
277
|
+
...rule,
|
|
278
|
+
configStatus: status,
|
|
279
|
+
isActive: status === "error" || status === "warn",
|
|
189
280
|
});
|
|
281
|
+
});
|
|
190
282
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
283
|
+
const categories = Object.keys(map).sort();
|
|
284
|
+
return {
|
|
285
|
+
categories,
|
|
286
|
+
rulesByCategory: map,
|
|
287
|
+
config,
|
|
288
|
+
configPath,
|
|
289
|
+
};
|
|
197
290
|
}
|
|
198
291
|
|
|
199
292
|
function updateScroll(idx, currentScroll, viewHeight) {
|
|
@@ -204,53 +297,77 @@ function updateScroll(idx, currentScroll, viewHeight) {
|
|
|
204
297
|
|
|
205
298
|
function openUrl(url) {
|
|
206
299
|
if (!url) return;
|
|
207
|
-
const cmd =
|
|
300
|
+
const cmd =
|
|
301
|
+
platform === "darwin"
|
|
302
|
+
? "open"
|
|
303
|
+
: platform === "win32"
|
|
304
|
+
? "start"
|
|
305
|
+
: "xdg-open";
|
|
208
306
|
exec(`${cmd} "${url}"`);
|
|
209
307
|
}
|
|
210
308
|
|
|
211
309
|
function chunkString(str, len) {
|
|
310
|
+
if (!str) return [];
|
|
212
311
|
const size = Math.ceil(str.length / len);
|
|
213
312
|
const r = Array(size);
|
|
214
|
-
for (let i = 0; i < size; i++)
|
|
215
|
-
r[i] = str.substring(i * len, (i + 1) * len);
|
|
216
|
-
}
|
|
313
|
+
for (let i = 0; i < size; i++) r[i] = str.substring(i * len, (i + 1) * len);
|
|
217
314
|
return r;
|
|
218
315
|
}
|
|
219
316
|
|
|
220
317
|
const write = (str) => stdout.write(str);
|
|
221
|
-
const enterAltScreen = () => write(
|
|
222
|
-
const exitAltScreen = () => write(
|
|
223
|
-
|
|
224
|
-
function drawBox(
|
|
318
|
+
const enterAltScreen = () => write("\x1b[?1049h\x1b[?25l");
|
|
319
|
+
const exitAltScreen = () => write("\x1b[?1049l\x1b[?25h");
|
|
320
|
+
|
|
321
|
+
function drawBox(
|
|
322
|
+
buffer,
|
|
323
|
+
x,
|
|
324
|
+
y,
|
|
325
|
+
width,
|
|
326
|
+
height,
|
|
327
|
+
title,
|
|
328
|
+
items,
|
|
329
|
+
selectedIdx,
|
|
330
|
+
scrollOffset,
|
|
331
|
+
isActive,
|
|
332
|
+
) {
|
|
225
333
|
const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
|
|
226
|
-
const titleClean =
|
|
227
|
-
|
|
228
|
-
const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(
|
|
334
|
+
const titleClean =
|
|
335
|
+
title.length > width - 6 ? title.substring(0, width - 7) + "…" : title;
|
|
336
|
+
const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(
|
|
337
|
+
width + borderColor.length - 1,
|
|
338
|
+
"─",
|
|
339
|
+
);
|
|
229
340
|
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
230
341
|
|
|
231
342
|
for (let i = 1; i < height - 1; i++) {
|
|
232
|
-
buffer.push(
|
|
343
|
+
buffer.push(
|
|
344
|
+
`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
|
|
345
|
+
);
|
|
233
346
|
}
|
|
234
|
-
buffer.push(
|
|
347
|
+
buffer.push(
|
|
348
|
+
`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
|
|
349
|
+
);
|
|
235
350
|
|
|
236
351
|
const innerHeight = height - 2;
|
|
237
|
-
|
|
238
352
|
items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
|
|
239
353
|
const absIdx = scrollOffset + i;
|
|
240
354
|
const rawText = (item.value || item).toString();
|
|
241
|
-
let display =
|
|
242
|
-
|
|
355
|
+
let display =
|
|
356
|
+
rawText.length > width - 4
|
|
357
|
+
? rawText.substring(0, width - 5) + "…"
|
|
358
|
+
: rawText.padEnd(width - 4);
|
|
243
359
|
let itemColor = COLORS.dim;
|
|
244
|
-
if (item.configStatus ===
|
|
245
|
-
else if (item.configStatus ===
|
|
360
|
+
if (item.configStatus === "error") itemColor = COLORS.error;
|
|
361
|
+
else if (item.configStatus === "warn") itemColor = COLORS.warn;
|
|
246
362
|
else if (item.isActive) itemColor = COLORS.success;
|
|
247
363
|
|
|
248
364
|
buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
|
|
249
|
-
|
|
250
365
|
if (absIdx === selectedIdx) {
|
|
251
|
-
buffer.push(
|
|
252
|
-
|
|
253
|
-
|
|
366
|
+
buffer.push(
|
|
367
|
+
isActive
|
|
368
|
+
? `${COLORS.selectedBg}${display}${COLORS.reset}`
|
|
369
|
+
: `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`,
|
|
370
|
+
);
|
|
254
371
|
} else {
|
|
255
372
|
buffer.push(`${itemColor}${display}${COLORS.reset}`);
|
|
256
373
|
}
|
|
@@ -259,76 +376,93 @@ function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollO
|
|
|
259
376
|
|
|
260
377
|
function drawStats(buffer, x, y, width, height, rules) {
|
|
261
378
|
const borderColor = COLORS.borderInactive;
|
|
262
|
-
|
|
263
|
-
|
|
379
|
+
const topBorder = `${borderColor}┌─ STATS `.padEnd(
|
|
380
|
+
width + borderColor.length - 1,
|
|
381
|
+
"─",
|
|
382
|
+
);
|
|
264
383
|
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
384
|
+
for (let i = 1; i < height - 1; i++)
|
|
385
|
+
buffer.push(
|
|
386
|
+
`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
|
|
387
|
+
);
|
|
388
|
+
buffer.push(
|
|
389
|
+
`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
|
|
390
|
+
);
|
|
268
391
|
|
|
269
392
|
let counts = { error: 0, warn: 0, off: 0 };
|
|
270
|
-
rules.forEach(r => {
|
|
271
|
-
if (r.configStatus ===
|
|
272
|
-
else if (r.configStatus ===
|
|
393
|
+
rules.forEach((r) => {
|
|
394
|
+
if (r.configStatus === "error") counts.error++;
|
|
395
|
+
else if (r.configStatus === "warn") counts.warn++;
|
|
273
396
|
else counts.off++;
|
|
274
397
|
});
|
|
275
398
|
|
|
276
399
|
const lines = [
|
|
277
|
-
{ label:
|
|
278
|
-
{ label:
|
|
279
|
-
{ label:
|
|
400
|
+
{ label: "Error", count: counts.error, color: COLORS.error },
|
|
401
|
+
{ label: "Warn", count: counts.warn, color: COLORS.warn },
|
|
402
|
+
{ label: "Off", count: counts.off, color: COLORS.dim },
|
|
280
403
|
];
|
|
281
404
|
|
|
282
405
|
lines.forEach((line, i) => {
|
|
283
406
|
if (i < height - 2) {
|
|
284
407
|
const numStr = String(line.count).padStart(3);
|
|
285
408
|
const labelStr = line.label.padEnd(width - 8);
|
|
286
|
-
buffer.push(
|
|
409
|
+
buffer.push(
|
|
410
|
+
`\x1b[${y + 1 + i};${x + 2}H${line.color}${labelStr}${numStr}${COLORS.reset}`,
|
|
411
|
+
);
|
|
287
412
|
}
|
|
288
413
|
});
|
|
289
414
|
}
|
|
290
415
|
|
|
291
416
|
function drawDetails(buffer, x, y, width, height, rule, isActive) {
|
|
292
417
|
const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
|
|
293
|
-
|
|
294
|
-
|
|
418
|
+
const topBorder = `${borderColor}┌─ DETAILS `.padEnd(
|
|
419
|
+
width + borderColor.length - 1,
|
|
420
|
+
"─",
|
|
421
|
+
);
|
|
295
422
|
buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
423
|
+
for (let i = 1; i < height - 1; i++)
|
|
424
|
+
buffer.push(
|
|
425
|
+
`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
|
|
426
|
+
);
|
|
427
|
+
buffer.push(
|
|
428
|
+
`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
|
|
429
|
+
);
|
|
299
430
|
|
|
300
431
|
if (!rule) return;
|
|
301
432
|
|
|
302
433
|
let statusDisplay = rule.configStatus.toUpperCase();
|
|
303
|
-
if (rule.configStatus ===
|
|
304
|
-
|
|
434
|
+
if (rule.configStatus === "error")
|
|
435
|
+
statusDisplay = `${COLORS.error}${statusDisplay}${COLORS.reset}`;
|
|
436
|
+
else if (rule.configStatus === "warn")
|
|
437
|
+
statusDisplay = `${COLORS.warn}${statusDisplay}${COLORS.reset}`;
|
|
305
438
|
else statusDisplay = `${COLORS.dim}${statusDisplay}${COLORS.reset}`;
|
|
306
439
|
|
|
307
440
|
const labels = [
|
|
308
|
-
[
|
|
309
|
-
[
|
|
310
|
-
[
|
|
311
|
-
[
|
|
312
|
-
[
|
|
313
|
-
[
|
|
314
|
-
[
|
|
315
|
-
[
|
|
441
|
+
["Name", rule.value],
|
|
442
|
+
["Status", statusDisplay],
|
|
443
|
+
["Category", rule.category],
|
|
444
|
+
["Scope", rule.scope],
|
|
445
|
+
["Fix", rule.fix],
|
|
446
|
+
["Default", rule.default ? "Yes" : "No"],
|
|
447
|
+
["Type-aware", rule.type_aware ? "Yes" : "No"],
|
|
448
|
+
["Docs", `Hit ${COLORS.highlight}ENTER${COLORS.reset} to open docs`],
|
|
316
449
|
];
|
|
317
450
|
|
|
318
451
|
let line = 0;
|
|
319
452
|
labels.forEach(([lbl, val]) => {
|
|
320
|
-
if (lbl ===
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
453
|
+
if (lbl === "Status" && line < height - 2) {
|
|
454
|
+
buffer.push(
|
|
455
|
+
`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`,
|
|
456
|
+
);
|
|
457
|
+
line++;
|
|
325
458
|
return;
|
|
326
459
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
chunks.forEach((chunk, i) => {
|
|
460
|
+
const chunks = chunkString(String(val || "N/A"), width - 15);
|
|
461
|
+
chunks.forEach((chunk) => {
|
|
330
462
|
if (line < height - 2) {
|
|
331
|
-
buffer.push(
|
|
463
|
+
buffer.push(
|
|
464
|
+
`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`,
|
|
465
|
+
);
|
|
332
466
|
line++;
|
|
333
467
|
}
|
|
334
468
|
});
|
|
@@ -340,48 +474,87 @@ function render() {
|
|
|
340
474
|
const currentCat = state.categories[state.selectedCatIdx];
|
|
341
475
|
const rules = state.rulesByCategory[currentCat] || [];
|
|
342
476
|
const rule = rules[state.selectedRuleIdx];
|
|
343
|
-
|
|
344
|
-
const boxHeight = rows - 4;
|
|
345
|
-
|
|
477
|
+
const boxHeight = rows - 5;
|
|
346
478
|
const col1W = Math.floor(columns * 0.2);
|
|
347
479
|
const col2W = Math.floor(columns * 0.3);
|
|
348
480
|
const col3W = columns - col1W - col2W - 2;
|
|
349
|
-
|
|
350
481
|
const statsHeight = 6;
|
|
351
482
|
const catListHeight = boxHeight - statsHeight;
|
|
352
483
|
|
|
353
|
-
const buffer = [
|
|
354
|
-
|
|
355
|
-
|
|
484
|
+
const buffer = ["\x1b[H\x1b[J"];
|
|
485
|
+
drawBox(
|
|
486
|
+
buffer,
|
|
487
|
+
1,
|
|
488
|
+
1,
|
|
489
|
+
col1W,
|
|
490
|
+
catListHeight,
|
|
491
|
+
"CATEGORIES",
|
|
492
|
+
state.categories,
|
|
493
|
+
state.selectedCatIdx,
|
|
494
|
+
state.scrollCat,
|
|
495
|
+
state.activePane === 0,
|
|
496
|
+
);
|
|
356
497
|
drawStats(buffer, 1, 1 + catListHeight, col1W, statsHeight, rules);
|
|
357
|
-
drawBox(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
498
|
+
drawBox(
|
|
499
|
+
buffer,
|
|
500
|
+
col1W + 1,
|
|
501
|
+
1,
|
|
502
|
+
col2W,
|
|
503
|
+
boxHeight,
|
|
504
|
+
`RULES (${rules.length})`,
|
|
505
|
+
rules,
|
|
506
|
+
state.selectedRuleIdx,
|
|
507
|
+
state.scrollRule,
|
|
508
|
+
state.activePane === 1,
|
|
509
|
+
);
|
|
510
|
+
drawDetails(
|
|
511
|
+
buffer,
|
|
512
|
+
col1W + col2W + 1,
|
|
513
|
+
1,
|
|
514
|
+
col3W,
|
|
515
|
+
boxHeight,
|
|
516
|
+
rule,
|
|
517
|
+
state.activePane === 2,
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
const msgColor = COLORS[state.messageType] || COLORS.reset;
|
|
521
|
+
buffer.push(
|
|
522
|
+
`\x1b[${rows - 3};2H${msgColor}● ${state.message}${COLORS.reset}`,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
const footerConfig = state.configPath
|
|
526
|
+
? `Config: ${state.configPath}`
|
|
527
|
+
: "No config loaded";
|
|
528
|
+
buffer.push(
|
|
529
|
+
`\x1b[${rows - 1};2H${COLORS.dim}Arrows: Nav | 1-3: Status | Space: Lint | Enter: Docs | Q: Quit | ${footerConfig}${COLORS.reset}`,
|
|
530
|
+
);
|
|
531
|
+
write(buffer.join(""));
|
|
361
532
|
}
|
|
362
533
|
|
|
363
|
-
let state = {
|
|
364
|
-
activePane: 0,
|
|
365
|
-
selectedCatIdx: 0,
|
|
366
|
-
selectedRuleIdx: 0,
|
|
367
|
-
scrollCat: 0,
|
|
368
|
-
scrollRule: 0,
|
|
369
|
-
...loadRules()
|
|
370
|
-
};
|
|
371
|
-
|
|
372
534
|
readline.emitKeypressEvents(stdin);
|
|
373
535
|
if (stdin.isTTY) stdin.setRawMode(true);
|
|
374
536
|
|
|
375
|
-
stdin.on(
|
|
376
|
-
const action =
|
|
537
|
+
stdin.on("keypress", (_, key) => {
|
|
538
|
+
const action =
|
|
539
|
+
KEY_MAP[key.name] ||
|
|
540
|
+
(key.ctrl && key.name === "c"
|
|
541
|
+
? { type: "EXIT" }
|
|
542
|
+
: KEY_MAP[key.sequence] || null);
|
|
377
543
|
if (!action) return;
|
|
378
544
|
|
|
379
|
-
if (action.type ===
|
|
545
|
+
if (action.type === "EXIT") {
|
|
380
546
|
exitAltScreen();
|
|
381
547
|
exit(0);
|
|
382
548
|
}
|
|
383
|
-
|
|
384
|
-
|
|
549
|
+
if (action.type === "RUN_LINT") {
|
|
550
|
+
runLint();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (action.type === "RUN_TYPE_AWARE_LINT") {
|
|
554
|
+
runLint(true);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (action.type === "OPEN_DOCS" && state.activePane === 1) {
|
|
385
558
|
const currentCat = state.categories[state.selectedCatIdx];
|
|
386
559
|
const rule = state.rulesByCategory[currentCat]?.[state.selectedRuleIdx];
|
|
387
560
|
if (rule) openUrl(rule.docs_url || rule.url);
|
|
@@ -392,6 +565,6 @@ stdin.on('keypress', (_, key) => {
|
|
|
392
565
|
render();
|
|
393
566
|
});
|
|
394
567
|
|
|
395
|
-
stdout.on(
|
|
568
|
+
stdout.on("resize", render);
|
|
396
569
|
enterAltScreen();
|
|
397
|
-
render();
|
|
570
|
+
render();
|