nou-cli 0.1.0 → 0.2.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 +140 -31
- package/dist/grammars/tree-sitter-bash.wasm +0 -0
- package/dist/grammars/tree-sitter-c.wasm +0 -0
- package/dist/grammars/tree-sitter-css.wasm +0 -0
- package/dist/grammars/tree-sitter-go.wasm +0 -0
- package/dist/grammars/tree-sitter-html.wasm +0 -0
- package/dist/grammars/tree-sitter-java.wasm +0 -0
- package/dist/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/grammars/tree-sitter-json.wasm +0 -0
- package/dist/grammars/tree-sitter-python.wasm +0 -0
- package/dist/grammars/tree-sitter-rust.wasm +0 -0
- package/dist/grammars/tree-sitter-toml.wasm +0 -0
- package/dist/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/grammars/tree-sitter-yaml.wasm +0 -0
- package/dist/index.js +55 -6
- package/dist/src/agent/index.js +37 -5
- package/dist/src/commands/index.js +16 -6
- package/dist/src/config/index.js +5 -2
- package/dist/src/diagnostics/index.js +235 -0
- package/dist/src/diagnostics/process.js +64 -0
- package/dist/src/diagnostics/providers.js +103 -0
- package/dist/src/diagnostics/types.js +1 -0
- package/dist/src/env.js +12 -0
- package/dist/src/history/index.js +113 -0
- package/dist/src/lsp/client.js +169 -0
- package/dist/src/lsp/index.js +169 -0
- package/dist/src/plugins/index.js +94 -3
- package/dist/src/providers/ids.js +3 -0
- package/dist/src/providers/ollama.js +2 -1
- package/dist/src/providers/openai.js +3 -2
- package/dist/src/syntax/index.js +240 -0
- package/dist/src/ui/App.js +268 -29
- package/dist/src/ui/Chat.js +11 -4
- package/dist/src/ui/ChatInput.js +128 -0
- package/dist/src/ui/CodeView.js +21 -5
- package/dist/src/ui/Editor.js +90 -21
- package/dist/src/ui/FileTree.js +6 -4
- package/dist/src/ui/Header.js +2 -2
- package/dist/src/ui/ProblemsView.js +71 -0
- package/dist/src/ui/Settings.js +30 -15
- package/dist/src/ui/text.js +6 -1
- package/dist/src/update/index.js +102 -0
- package/package.json +9 -4
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
4
5
|
import { artWork } from "../util/index.js";
|
|
5
6
|
import { highlightLines, sideBySide } from "./text.js";
|
|
6
7
|
import { C, fileIcon, gradient } from "./theme.js";
|
|
7
8
|
import { Pane } from "./Pane.js";
|
|
8
|
-
export function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, onViewport }) {
|
|
9
|
+
export function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, onViewport }) {
|
|
9
10
|
const [top, setTop] = useState(0);
|
|
10
11
|
const rows = Math.max(1, height - 3);
|
|
11
12
|
const inner = width - 2;
|
|
12
|
-
const lines = useMemo(() => (filePath ? highlightLines(filePath, content) : []), [filePath, content]);
|
|
13
|
+
const lines = useMemo(() => (filePath ? highlightLines(filePath, content) : []), [filePath, content, syntaxVersion]);
|
|
14
|
+
const problemsByLine = useMemo(() => {
|
|
15
|
+
const map = new Map();
|
|
16
|
+
for (const d of problems)
|
|
17
|
+
map.set(d.line, [...(map.get(d.line) ?? []), d]);
|
|
18
|
+
return map;
|
|
19
|
+
}, [problems]);
|
|
13
20
|
const diffRows = useMemo(() => (diff ? sideBySide(diff.before, diff.after) : []), [diff]);
|
|
14
21
|
const total = diff ? diffRows.length : lines.length;
|
|
15
22
|
// Jump to the first change whenever a new file or diff is shown.
|
|
@@ -19,6 +26,10 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
19
26
|
: marked.size ? Math.min(...marked) - 1 : 0;
|
|
20
27
|
setTop(Math.max(0, first - 3));
|
|
21
28
|
}, [filePath, diff, diffRows, marked]);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (focusLine && !diff)
|
|
31
|
+
setTop(Math.max(0, focusLine.line - Math.floor(rows / 3)));
|
|
32
|
+
}, [focusLine]);
|
|
22
33
|
const maxTop = Math.max(0, total - rows);
|
|
23
34
|
const start = Math.min(top, maxTop);
|
|
24
35
|
useEffect(() => {
|
|
@@ -60,7 +71,7 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
60
71
|
return (_jsx(Box, { width: w, children: _jsxs(Text, { wrap: "truncate-end", ...(color ? { color } : { color: C.dim }), children: [String(c.n).padStart(gutter - 1), changed ? side : " ", c.text || " "] }) }));
|
|
61
72
|
};
|
|
62
73
|
const { icon, color } = fileIcon(diff.path);
|
|
63
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: "black", children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
|
|
74
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: "black", children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), diffNote ? _jsxs(Text, { color: diffNote.startsWith("✓") ? C.green : C.red, children: [" ", diffNote] }) : null, _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
|
|
64
75
|
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, ...(pending ? { accent: C.yellow } : {}), children: _jsx(Box, { flexDirection: "column", children: diffRows.slice(start, start + rows).map((row, i) => (_jsxs(Box, { flexDirection: "row", children: [cell(row.left, "-", row.changed, half), _jsx(Text, { color: C.dim, children: "\u2502" }), cell(row.right, "+", row.changed, inner - half - 1)] }, start + i))) }) }));
|
|
65
76
|
}
|
|
66
77
|
if (!filePath) {
|
|
@@ -75,10 +86,15 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
75
86
|
}
|
|
76
87
|
const gutter = String(lines.length).length;
|
|
77
88
|
const { icon, color } = fileIcon(filePath);
|
|
78
|
-
const
|
|
89
|
+
const errorCount = problems.filter((d) => d.severity === "error").length;
|
|
90
|
+
const warningCount = problems.filter((d) => d.severity === "warning").length;
|
|
91
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused ? " e edit · v diff" : ""] })] }));
|
|
79
92
|
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, children: _jsx(Box, { flexDirection: "column", children: lines.slice(start, start + rows).map((line, i) => {
|
|
80
93
|
const n = start + i + 1;
|
|
81
94
|
const isMarked = marked.has(n);
|
|
82
|
-
|
|
95
|
+
const here = problemsByLine.get(n);
|
|
96
|
+
const worst = here?.[0];
|
|
97
|
+
const style = worst ? SEVERITY_STYLE[worst.severity] : null;
|
|
98
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), line || " ", worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
|
|
83
99
|
}) }) }));
|
|
84
100
|
}
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -3,17 +3,26 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { Box, Text, useInput, usePaste } from "ink";
|
|
4
4
|
import { highlightLines } from "./text.js";
|
|
5
5
|
import { C, fileIcon } from "./theme.js";
|
|
6
|
+
import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
6
7
|
import { Pane } from "./Pane.js";
|
|
7
8
|
const expand = (s) => s.replace(/\t/g, " ");
|
|
8
9
|
/** A small modal text editor: arrows to move, type to insert, Ctrl+S save, Ctrl+Z undo, Esc leave. */
|
|
9
|
-
export function Editor({ width, height, focused, filePath, initial, baseline, onSave, onExit, onState }) {
|
|
10
|
+
export function Editor({ width, height, focused, filePath, initial, baseline, startLine, problems, autoSave, syntaxVersion, onSave, onExit, onState, onHover, onDefinition }) {
|
|
10
11
|
const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
|
|
11
12
|
const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
|
|
12
|
-
const [buf, setBuf] = useState(() =>
|
|
13
|
+
const [buf, setBuf] = useState(() => {
|
|
14
|
+
const lines = initial.replace(/\r\n/g, "\n").split("\n");
|
|
15
|
+
const row = Math.max(0, Math.min(lines.length - 1, (startLine ?? 1) - 1));
|
|
16
|
+
return { lines, row, col: lines[row].match(/^\s*/)[0].length };
|
|
17
|
+
});
|
|
18
|
+
const [hover, setHover] = useState(null);
|
|
13
19
|
const [saved, setSaved] = useState(() => baseline.replace(/\r\n/g, "\n"));
|
|
14
20
|
const [confirmExit, setConfirmExit] = useState(false);
|
|
15
21
|
const [flash, setFlash] = useState(null);
|
|
16
22
|
const undo = useRef([]);
|
|
23
|
+
const redo = useRef([]);
|
|
24
|
+
// Consecutive typing within a word is one undo step, like VS Code.
|
|
25
|
+
const lastEdit = useRef({ kind: "", time: 0 });
|
|
17
26
|
const view = useRef({ top: 0, left: 0 });
|
|
18
27
|
const dirty = useMemo(() => buf.lines.join("\n") !== saved, [buf.lines, saved]);
|
|
19
28
|
useEffect(() => onState({ line: buf.row + 1, col: buf.col + 1, dirty, lines: buf.lines }), [buf, dirty]);
|
|
@@ -23,13 +32,27 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
|
|
|
23
32
|
const t = setTimeout(() => setFlash(null), 1500);
|
|
24
33
|
return () => clearTimeout(t);
|
|
25
34
|
}, [flash]);
|
|
26
|
-
const edit = (fn) => setBuf((b) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
const edit = (fn, kind = "other") => setBuf((b) => {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const grouped = kind !== "other" && kind === lastEdit.current.kind && now - lastEdit.current.time < 1000;
|
|
38
|
+
if (!grouped) {
|
|
39
|
+
undo.current.push(b);
|
|
40
|
+
if (undo.current.length > 500)
|
|
41
|
+
undo.current.shift();
|
|
42
|
+
}
|
|
43
|
+
redo.current = [];
|
|
44
|
+
lastEdit.current = { kind, time: now };
|
|
30
45
|
return fn(b);
|
|
31
46
|
});
|
|
32
|
-
const
|
|
47
|
+
const step = (from, to) => setBuf((b) => {
|
|
48
|
+
const target = from.current.pop();
|
|
49
|
+
if (!target)
|
|
50
|
+
return b;
|
|
51
|
+
to.current.push(b);
|
|
52
|
+
lastEdit.current = { kind: "", time: 0 };
|
|
53
|
+
return target;
|
|
54
|
+
});
|
|
55
|
+
const insert = (text, kind = "other") => edit((b) => {
|
|
33
56
|
const pieces = text.replace(/\r\n?/g, "\n").split("\n");
|
|
34
57
|
const line = b.lines[b.row];
|
|
35
58
|
const before = line.slice(0, b.col);
|
|
@@ -42,19 +65,31 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
|
|
|
42
65
|
const last = pieces[pieces.length - 1];
|
|
43
66
|
lines.splice(b.row, 1, before + pieces[0], ...pieces.slice(1, -1), last + after);
|
|
44
67
|
return { lines, row: b.row + pieces.length - 1, col: last.length };
|
|
45
|
-
});
|
|
68
|
+
}, kind);
|
|
46
69
|
const move = (fn) => setBuf((b) => {
|
|
47
70
|
const next = { ...b, ...fn(b) };
|
|
48
71
|
next.row = Math.max(0, Math.min(next.lines.length - 1, next.row));
|
|
49
72
|
next.col = Math.max(0, Math.min(next.lines[next.row].length, next.col));
|
|
50
73
|
return next;
|
|
51
74
|
});
|
|
52
|
-
const save = () => {
|
|
75
|
+
const save = (auto = false) => {
|
|
53
76
|
const text = buf.lines.join("\n");
|
|
54
77
|
onSave(text.replace(/\n/g, eol));
|
|
55
78
|
setSaved(text);
|
|
56
|
-
setFlash("Saved ✓");
|
|
79
|
+
setFlash(auto ? "Auto-saved ✓" : "Saved ✓");
|
|
57
80
|
};
|
|
81
|
+
// Auto-save: a second after you stop typing, or when you leave the editor.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (autoSave !== "delay" || !dirty)
|
|
84
|
+
return;
|
|
85
|
+
const timer = setTimeout(() => save(true), 1000);
|
|
86
|
+
return () => clearTimeout(timer);
|
|
87
|
+
}, [buf.lines, dirty, autoSave]);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (autoSave !== "off" && !focused && dirty)
|
|
90
|
+
save(true);
|
|
91
|
+
}, [focused]);
|
|
92
|
+
const position = () => ({ text: buf.lines.join("\n"), line: buf.row + 1, col: buf.col + 1 });
|
|
58
93
|
const rows = Math.max(1, height - 4);
|
|
59
94
|
usePaste((text) => insert(text), { isActive: focused });
|
|
60
95
|
useInput((input, key) => {
|
|
@@ -69,16 +104,36 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
|
|
|
69
104
|
setConfirmExit(false);
|
|
70
105
|
return;
|
|
71
106
|
}
|
|
107
|
+
if (hover)
|
|
108
|
+
setHover(null);
|
|
109
|
+
if (key.ctrl && input === "c")
|
|
110
|
+
return; // the app handles Ctrl+C (press twice to quit)
|
|
72
111
|
if (key.ctrl && input === "s")
|
|
73
112
|
return save();
|
|
74
|
-
if (key.ctrl && input === "z")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
113
|
+
if (key.ctrl && input === "z")
|
|
114
|
+
return step(undo, redo);
|
|
115
|
+
if (key.ctrl && input === "y")
|
|
116
|
+
return step(redo, undo);
|
|
117
|
+
if (key.ctrl && input === "k") {
|
|
118
|
+
const { text, line, col } = position();
|
|
119
|
+
setFlash("…");
|
|
120
|
+
void onHover(text, line, col).then((info) => {
|
|
121
|
+
setFlash(null);
|
|
122
|
+
setHover(info ?? "No info here (needs a language server for this file type).");
|
|
123
|
+
});
|
|
78
124
|
return;
|
|
79
125
|
}
|
|
80
|
-
if (key.
|
|
126
|
+
if (key.ctrl && input === "g") {
|
|
127
|
+
const { text, line, col } = position();
|
|
128
|
+
return onDefinition(text, line, col);
|
|
129
|
+
}
|
|
130
|
+
if (key.escape) {
|
|
131
|
+
if (dirty && autoSave !== "off") {
|
|
132
|
+
save(true);
|
|
133
|
+
return onExit();
|
|
134
|
+
}
|
|
81
135
|
return dirty ? setConfirmExit(true) : onExit();
|
|
136
|
+
}
|
|
82
137
|
if (key.upArrow)
|
|
83
138
|
return move((b) => ({ row: b.row - 1 }));
|
|
84
139
|
if (key.downArrow)
|
|
@@ -116,7 +171,7 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
|
|
|
116
171
|
const prev = lines[b.row - 1];
|
|
117
172
|
lines.splice(b.row - 1, 2, prev + lines[b.row]);
|
|
118
173
|
return { lines, row: b.row - 1, col: prev.length };
|
|
119
|
-
});
|
|
174
|
+
}, "delete");
|
|
120
175
|
}
|
|
121
176
|
if (key.delete) {
|
|
122
177
|
return edit((b) => {
|
|
@@ -129,8 +184,9 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
|
|
|
129
184
|
return { ...b, lines };
|
|
130
185
|
});
|
|
131
186
|
}
|
|
187
|
+
// Letters/digits group into one undo step per word; spaces and punctuation start a new one.
|
|
132
188
|
if (input && !key.ctrl && !key.meta)
|
|
133
|
-
insert(input);
|
|
189
|
+
insert(input, /^\w+$/.test(input) ? "type" : "other");
|
|
134
190
|
}, { isActive: focused });
|
|
135
191
|
// Keep the cursor on screen.
|
|
136
192
|
const gutter = String(buf.lines.length).length + 2;
|
|
@@ -146,20 +202,33 @@ export function Editor({ width, height, focused, filePath, initial, baseline, on
|
|
|
146
202
|
if (cursorX >= v.left + textWidth)
|
|
147
203
|
v.left = cursorX - textWidth + 8;
|
|
148
204
|
const visible = buf.lines.slice(v.top, v.top + rows);
|
|
149
|
-
const colored = useMemo(
|
|
205
|
+
const colored = useMemo(
|
|
206
|
+
// Scrolled sideways: every line shifts by the same amount as the cursor line (plain text, since
|
|
207
|
+
// slicing highlighted text could cut a color code in half).
|
|
208
|
+
() => (v.left === 0 ? highlightLines(filePath, visible.join("\n")) : visible.map((l) => expand(l).slice(v.left, v.left + textWidth))), [filePath, visible.join("\n"), v.left, textWidth, syntaxVersion]);
|
|
150
209
|
const { icon, color } = fileIcon(filePath);
|
|
210
|
+
const byLine = new Map();
|
|
211
|
+
for (const d of problems)
|
|
212
|
+
byLine.set(d.line, [...(byLine.get(d.line) ?? []), d]);
|
|
213
|
+
const cursorProblem = byLine.get(buf.row + 1)?.[0];
|
|
151
214
|
const status = confirmExit
|
|
152
215
|
? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
|
|
153
|
-
: flash ??
|
|
216
|
+
: flash ?? (cursorProblem
|
|
217
|
+
? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
|
|
218
|
+
: `Ln ${buf.row + 1}, Col ${buf.col + 1}${dirty ? " ● modified" : ""}`);
|
|
219
|
+
const statusColor = confirmExit ? C.red : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
|
|
220
|
+
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
154
221
|
return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : "", " ", _jsx(Text, { backgroundColor: C.green, color: "black", children: " \u270E EDITING " })] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsx(Box, { flexDirection: "column", height: rows, children: visible.map((line, i) => {
|
|
155
222
|
const n = v.top + i;
|
|
156
223
|
const isCursorLine = n === buf.row;
|
|
157
|
-
const
|
|
224
|
+
const problem = byLine.get(n + 1)?.[0];
|
|
225
|
+
const mark = problem ? SEVERITY_STYLE[problem.severity] : null;
|
|
226
|
+
const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
|
|
158
227
|
if (!isCursorLine) {
|
|
159
228
|
return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
|
|
160
229
|
}
|
|
161
230
|
const shown = expand(line).slice(v.left, v.left + textWidth);
|
|
162
231
|
const at = cursorX - v.left;
|
|
163
232
|
return (_jsxs(Text, { wrap: "truncate-end", children: [num, _jsx(Text, { color: C.text, children: shown.slice(0, at) }), _jsx(Text, { inverse: true, color: C.green, children: shown[at] ?? " " }), _jsx(Text, { color: C.text, children: shown.slice(at + 1) })] }, n));
|
|
164
|
-
}) }),
|
|
233
|
+
}) }), hoverLines.length > 0 && (_jsx(Box, { position: "absolute", top: Math.max(1, rows - hoverLines.length - 1), left: gutter + 1, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, width: Math.min(width - gutter - 3, 2 + Math.max(...hoverLines.map((l) => l.length))), children: hoverLines.map((l, i) => _jsx(Text, { color: C.text, wrap: "truncate-end", children: l.padEnd(width) }, i)) })), _jsxs(Text, { wrap: "truncate-end", color: statusColor, children: [` ${status}`, _jsx(Text, { color: C.dim, children: confirmExit ? "" : ` Ctrl+S save${autoSave === "off" ? "" : " (auto)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close` })] })] }));
|
|
165
234
|
}
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -30,7 +30,7 @@ function flatten(expanded) {
|
|
|
30
30
|
walk(".", 0);
|
|
31
31
|
return nodes;
|
|
32
32
|
}
|
|
33
|
-
export function FileTree({ width, height, focused, openFile, reveal, version, changed, git, onOpen, onHover }) {
|
|
33
|
+
export function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, onOpen, onHover }) {
|
|
34
34
|
const [expanded, setExpanded] = useState(() => new Set(["src"]));
|
|
35
35
|
const [cursor, setCursor] = useState(0);
|
|
36
36
|
const [changesOnly, setChangesOnly] = useState(false);
|
|
@@ -129,10 +129,12 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
|
|
|
129
129
|
const { icon, color } = node.isDir
|
|
130
130
|
? { icon: expanded.has(node.path) ? "▾" : "▸", color: C.accent }
|
|
131
131
|
: fileIcon(node.path);
|
|
132
|
-
const
|
|
133
|
-
const
|
|
132
|
+
const counts = problemCounts.get(node.path);
|
|
133
|
+
const problemMark = counts?.errors ? "✖" : counts?.warnings ? "▲" : "";
|
|
134
|
+
const marker = problemMark || code || (changed.has(node.path) ? "●" : dirtyDirs.has(node.path) ? "•" : "");
|
|
135
|
+
const markerColor = counts?.errors ? C.red : counts?.warnings ? C.yellow : code ? GIT_COLORS[code] ?? C.yellow : changed.has(node.path) ? C.green : C.yellow;
|
|
134
136
|
const label = `${" ".repeat(node.depth)}${icon} ${node.name}`;
|
|
135
|
-
const nameColor = isOpen ? C.cyan : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
|
|
137
|
+
const nameColor = isOpen ? C.cyan : counts?.errors ? C.red : code ? GIT_COLORS[code] : node.isDir ? C.text : undefined;
|
|
136
138
|
const room = Math.max(1, inner - marker.length - 1);
|
|
137
139
|
const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
|
|
138
140
|
const iconAt = shown.indexOf(icon);
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { Box, Text, useAnimation } from "ink";
|
|
4
4
|
import { C, SPINNER, gradient } from "./theme.js";
|
|
5
|
-
export function Header({ width, branch, changes, model, local, busy }) {
|
|
5
|
+
export function Header({ width, branch, changes, model, local, busy, update }) {
|
|
6
6
|
const { frame } = useAnimation({ interval: 120, isActive: !!busy });
|
|
7
7
|
const project = path.basename(process.cwd());
|
|
8
|
-
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }), _jsxs(Text, { children: [_jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] })] }));
|
|
8
|
+
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }), _jsxs(Text, { children: [update && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] })] }));
|
|
9
9
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
|
+
import { C, SPINNER, fileIcon } from "./theme.js";
|
|
5
|
+
import { Pane } from "./Pane.js";
|
|
6
|
+
export const SEVERITY_STYLE = {
|
|
7
|
+
error: { icon: "✖", color: C.red },
|
|
8
|
+
warning: { icon: "▲", color: C.yellow },
|
|
9
|
+
info: { icon: "ℹ", color: C.accent },
|
|
10
|
+
};
|
|
11
|
+
export function ProblemsView({ width, height, focused, problems, checking, onJump, onClose }) {
|
|
12
|
+
const [cursor, setCursor] = useState(0);
|
|
13
|
+
const [onlyErrors, setOnlyErrors] = useState(false);
|
|
14
|
+
const { frame } = useAnimation({ interval: 80, isActive: checking.length > 0 });
|
|
15
|
+
const rows = useMemo(() => {
|
|
16
|
+
const list = onlyErrors ? problems.filter((d) => d.severity === "error") : problems;
|
|
17
|
+
const out = [];
|
|
18
|
+
let current = "";
|
|
19
|
+
for (const d of list) {
|
|
20
|
+
if (d.file !== current) {
|
|
21
|
+
current = d.file;
|
|
22
|
+
const inFile = list.filter((x) => x.file === d.file);
|
|
23
|
+
out.push({
|
|
24
|
+
kind: "file",
|
|
25
|
+
file: d.file,
|
|
26
|
+
errors: inFile.filter((x) => x.severity === "error").length,
|
|
27
|
+
warnings: inFile.filter((x) => x.severity === "warning").length,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
out.push({ kind: "problem", d });
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}, [problems, onlyErrors]);
|
|
34
|
+
useEffect(() => setCursor((c) => Math.min(c, Math.max(0, rows.length - 1))), [rows.length]);
|
|
35
|
+
const visibleRows = Math.max(1, height - 4);
|
|
36
|
+
useInput((input, key) => {
|
|
37
|
+
if (key.upArrow || input === "k")
|
|
38
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
39
|
+
else if (key.downArrow || input === "j")
|
|
40
|
+
setCursor((c) => Math.min(rows.length - 1, c + 1));
|
|
41
|
+
else if (key.pageUp)
|
|
42
|
+
setCursor((c) => Math.max(0, c - visibleRows));
|
|
43
|
+
else if (key.pageDown)
|
|
44
|
+
setCursor((c) => Math.min(rows.length - 1, c + visibleRows));
|
|
45
|
+
else if (input === "e")
|
|
46
|
+
setOnlyErrors((v) => !v);
|
|
47
|
+
else if (input === "p" || key.escape)
|
|
48
|
+
onClose();
|
|
49
|
+
else if (key.return) {
|
|
50
|
+
const row = rows[cursor];
|
|
51
|
+
if (row?.kind === "problem")
|
|
52
|
+
onJump(row.d.file, row.d.line);
|
|
53
|
+
else if (row?.kind === "file")
|
|
54
|
+
onJump(row.file, problems.find((d) => d.file === row.file)?.line ?? 1);
|
|
55
|
+
}
|
|
56
|
+
}, { isActive: focused });
|
|
57
|
+
const errors = problems.filter((d) => d.severity === "error").length;
|
|
58
|
+
const warnings = problems.filter((d) => d.severity === "warning").length;
|
|
59
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(visibleRows / 2), rows.length - visibleRows));
|
|
60
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsxs(Text, { color: C.red, children: ["\u2716 ", errors] }), " ", _jsxs(Text, { color: C.yellow, children: ["\u25B2 ", warnings] }), " Problems", checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " checking ", checking.join(", ")] }), _jsx(Text, { color: C.dim, children: onlyErrors ? " (errors only)" : "" })] }));
|
|
61
|
+
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, ...(focused ? { accent: C.red } : {}), children: [_jsxs(Box, { flexDirection: "column", height: visibleRows, children: [rows.length === 0 && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.green, children: checking.length ? "Checking…" : "✓ No problems found" }), _jsx(Text, { color: C.dim, children: "Checked by the syntax parser, plus ESLint, TypeScript, Ruff and language servers when your project has them." })] })), rows.slice(start, start + visibleRows).map((row, i) => {
|
|
62
|
+
const selected = focused && start + i === cursor;
|
|
63
|
+
const bg = selected ? { backgroundColor: C.panel } : {};
|
|
64
|
+
if (row.kind === "file") {
|
|
65
|
+
const { icon, color } = fileIcon(row.file);
|
|
66
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [_jsx(Text, { color: color, children: icon }), " ", _jsx(Text, { bold: true, color: C.text, children: row.file }), row.errors > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", row.errors] }), row.warnings > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", row.warnings] })] }, `f${row.file}`));
|
|
67
|
+
}
|
|
68
|
+
const style = SEVERITY_STYLE[row.d.severity];
|
|
69
|
+
return (_jsxs(Text, { wrap: "truncate-end", ...bg, children: [" ", _jsx(Text, { color: style.color, children: style.icon }), _jsxs(Text, { color: C.dim, children: [" ", String(row.d.line).padStart(4), ":", String(row.d.col).padEnd(3), " "] }), _jsx(Text, { ...(selected ? { color: C.text } : {}), children: row.d.message }), _jsxs(Text, { color: C.dim, children: [" ", row.d.source, row.d.code ? ` ${row.d.code}` : ""] })] }, `${row.d.file}:${row.d.line}:${row.d.col}:${i}`));
|
|
70
|
+
})] }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: " \u2191\u2193 move \u00B7 Enter go to line \u00B7 e errors only \u00B7 p/Esc close" })] }));
|
|
71
|
+
}
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -2,13 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
|
-
import { EFFORTS } from "../config/index.js";
|
|
6
|
-
import { PLUGIN_DIRS } from "../plugins/index.js";
|
|
5
|
+
import { AUTO_SAVE, EFFORTS } from "../config/index.js";
|
|
6
|
+
import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
|
|
7
|
+
import { currentVersion } from "../update/index.js";
|
|
7
8
|
import { describe, providers } from "../providers/index.js";
|
|
8
9
|
import { C, SPINNER } from "./theme.js";
|
|
9
10
|
const TABS = ["Model", "Preferences", "Plugins"];
|
|
10
11
|
const PREFS = [
|
|
11
12
|
{ key: "effort", label: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
|
|
13
|
+
{ key: "autoSave", label: "Auto-save", help: "delay: a second after you stop typing · focus: when you leave the editor · off: only Ctrl+S." },
|
|
12
14
|
{ key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
|
|
13
15
|
{ key: "gitContext", label: "Git context", help: "Give the model your branch, changes and commits, plus git tools." },
|
|
14
16
|
{ key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
|
|
@@ -86,10 +88,13 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
|
|
|
86
88
|
}
|
|
87
89
|
else if (tab === "Preferences" && (key.return || input === " " || key.leftArrow || key.rightArrow)) {
|
|
88
90
|
const pref = PREFS[row];
|
|
91
|
+
const step = key.leftArrow ? -1 : 1;
|
|
89
92
|
if (pref.key === "effort") {
|
|
90
|
-
const step = key.leftArrow ? -1 : 1;
|
|
91
93
|
onChange({ effort: EFFORTS[(EFFORTS.indexOf(settings.effort) + step + EFFORTS.length) % EFFORTS.length] });
|
|
92
94
|
}
|
|
95
|
+
else if (pref.key === "autoSave") {
|
|
96
|
+
onChange({ autoSave: AUTO_SAVE[(AUTO_SAVE.indexOf(settings.autoSave) + step + AUTO_SAVE.length) % AUTO_SAVE.length] });
|
|
97
|
+
}
|
|
93
98
|
else {
|
|
94
99
|
onChange({ [pref.key]: !settings[pref.key] });
|
|
95
100
|
}
|
|
@@ -98,7 +103,7 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
|
|
|
98
103
|
if (input === "r")
|
|
99
104
|
onRescanPlugins();
|
|
100
105
|
else if ((key.return || input === " ") && plugins[row])
|
|
101
|
-
onTogglePlugin(plugins[row].id, !
|
|
106
|
+
onTogglePlugin(plugins[row].id, !isEnabled(plugins[row], settings));
|
|
102
107
|
}
|
|
103
108
|
});
|
|
104
109
|
const boxHeight = height - 6;
|
|
@@ -130,21 +135,31 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
|
|
|
130
135
|
return (_jsx(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: PREFS.map((pref, i) => {
|
|
131
136
|
const selected = i === row;
|
|
132
137
|
const value = settings[pref.key];
|
|
133
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: selected, children: pref.label.padEnd(26) }), pref.key === "effort"
|
|
134
|
-
? _jsx(Text, { children: EFFORTS.map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${e}]` : ` ${e} ` }, e)) })
|
|
138
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: selected, children: pref.label.padEnd(26) }), pref.key === "effort" || pref.key === "autoSave"
|
|
139
|
+
? _jsx(Text, { children: (pref.key === "effort" ? EFFORTS : AUTO_SAVE).map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${e}]` : ` ${e} ` }, e)) })
|
|
135
140
|
: toggle(value, pref.warn)] }), _jsxs(Text, { color: C.dim, children: [" ", pref.help] })] }, pref.key));
|
|
136
141
|
}) }));
|
|
137
142
|
}
|
|
138
|
-
return (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [plugins.map((p, i) => {
|
|
144
|
+
const selected = i === row;
|
|
145
|
+
const state = trustState(p, settings);
|
|
146
|
+
const enabled = state === "on";
|
|
147
|
+
const parts = p.plugin
|
|
148
|
+
? [
|
|
149
|
+
(p.plugin.tools ?? []).length && `${p.plugin.tools.length} tools`,
|
|
150
|
+
(p.plugin.commands ?? []).length && `${p.plugin.commands.length} commands`,
|
|
151
|
+
(p.plugin.diagnostics ?? []).length && "checker",
|
|
152
|
+
].filter(Boolean)
|
|
153
|
+
: [];
|
|
154
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
|
|
155
|
+
? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u26A0 Changed since you enabled it. Review the code, then press Enter to enable again." })
|
|
156
|
+
: p.error
|
|
157
|
+
? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2716 ", p.error] })
|
|
158
|
+
: _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it.")] })] }, p.id));
|
|
159
|
+
}), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Add your own: drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Example: examples/plugins/word-count.mjs in the Nou repo." })] }));
|
|
145
160
|
};
|
|
146
161
|
const hints = tab === "Model"
|
|
147
162
|
? column === "models" ? "↑↓ choose · Enter use model · ← providers" : "↑↓ provider · Enter/→ pick a model"
|
|
148
|
-
: tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change
|
|
149
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
|
|
163
|
+
: tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
|
|
164
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) }), _jsxs(Text, { color: C.dim, children: [" nou v", currentVersion()] })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
|
|
150
165
|
}
|
package/dist/src/ui/text.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { highlight, supportsLanguage } from "cli-highlight";
|
|
3
3
|
import { diffLines } from "diff";
|
|
4
|
+
import { highlightTree } from "../syntax/index.js";
|
|
4
5
|
const LANGUAGES = {
|
|
5
6
|
".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
6
7
|
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
@@ -16,8 +17,12 @@ export function splitLines(text) {
|
|
|
16
17
|
return lines;
|
|
17
18
|
}
|
|
18
19
|
export function highlightLines(filePath, text) {
|
|
19
|
-
const language = LANGUAGES[path.extname(filePath).toLowerCase()];
|
|
20
20
|
const plain = splitLines(text);
|
|
21
|
+
// tree-sitter when the grammar is loaded (accurate); highlight.js otherwise.
|
|
22
|
+
const tree = highlightTree(filePath, plain.join("\n"));
|
|
23
|
+
if (tree)
|
|
24
|
+
return tree;
|
|
25
|
+
const language = LANGUAGES[path.extname(filePath).toLowerCase()];
|
|
21
26
|
if (!language || !supportsLanguage(language) || text.length > 200_000)
|
|
22
27
|
return plain;
|
|
23
28
|
try {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { NOU_HOME } from "../config/index.js";
|
|
6
|
+
export const PACKAGE_NAME = "nou-cli";
|
|
7
|
+
const CACHE = path.join(NOU_HOME, "update.json");
|
|
8
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
9
|
+
/** Nou's own package.json (works from source and from the installed dist build). */
|
|
10
|
+
function ownPackage() {
|
|
11
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
for (let i = 0; i < 5; i++) {
|
|
13
|
+
const file = path.join(dir, "package.json");
|
|
14
|
+
if (existsSync(file)) {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(file, "utf-8"));
|
|
16
|
+
if (pkg.name === PACKAGE_NAME)
|
|
17
|
+
return { version: pkg.version, dir };
|
|
18
|
+
}
|
|
19
|
+
dir = path.dirname(dir);
|
|
20
|
+
}
|
|
21
|
+
return { version: "0.0.0", dir };
|
|
22
|
+
}
|
|
23
|
+
export const currentVersion = () => ownPackage().version;
|
|
24
|
+
/** Running from a git checkout (npm run dev) rather than an npm install: never nag about updates. */
|
|
25
|
+
export const isDevCheckout = () => existsSync(path.join(ownPackage().dir, ".git"));
|
|
26
|
+
/** -1, 0 or 1. Plain x.y.z comparison; pre-release tags sort before the release. */
|
|
27
|
+
export function compareVersions(a, b) {
|
|
28
|
+
const parse = (v) => {
|
|
29
|
+
const [main, pre] = v.replace(/^v/, "").split("-", 2);
|
|
30
|
+
return { nums: main.split(".").map((n) => Number(n) || 0), pre };
|
|
31
|
+
};
|
|
32
|
+
const x = parse(a);
|
|
33
|
+
const y = parse(b);
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
|
|
36
|
+
if (d)
|
|
37
|
+
return Math.sign(d);
|
|
38
|
+
}
|
|
39
|
+
if (x.pre && !y.pre)
|
|
40
|
+
return -1;
|
|
41
|
+
if (!x.pre && y.pre)
|
|
42
|
+
return 1;
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
async function fetchLatest(timeout) {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { signal: AbortSignal.timeout(timeout) });
|
|
48
|
+
if (!res.ok)
|
|
49
|
+
return null;
|
|
50
|
+
return (await res.json()).version ?? null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The newer version available on npm, or null. Asks npm at most once a day (cached in ~/.nou/update.json)
|
|
58
|
+
* and never waits more than `timeout` ms, so startup stays fast offline.
|
|
59
|
+
*/
|
|
60
|
+
export async function checkForUpdate({ force = false, timeout = 1500 } = {}) {
|
|
61
|
+
if (!force && (process.env.NOU_NO_UPDATE_CHECK || isDevCheckout()))
|
|
62
|
+
return null;
|
|
63
|
+
let latest = null;
|
|
64
|
+
try {
|
|
65
|
+
const cached = JSON.parse(readFileSync(CACHE, "utf-8"));
|
|
66
|
+
if (!force && Date.now() - cached.checkedAt < DAY)
|
|
67
|
+
latest = cached.latest;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// no cache yet
|
|
71
|
+
}
|
|
72
|
+
if (!latest) {
|
|
73
|
+
latest = await fetchLatest(timeout);
|
|
74
|
+
if (latest) {
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(NOU_HOME, { recursive: true });
|
|
77
|
+
writeFileSync(CACHE, JSON.stringify({ latest, checkedAt: Date.now() }));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// caching is optional
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return latest && compareVersions(latest, currentVersion()) > 0 ? latest : null;
|
|
85
|
+
}
|
|
86
|
+
/** Runs `npm install -g nou-cli@<version>`, showing npm's output. Resolves to success. */
|
|
87
|
+
export function installUpdate(version = "latest") {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
// npm is npm.cmd on Windows, which needs a shell; the arguments are fixed, not user input.
|
|
90
|
+
const child = spawn("npm", ["install", "-g", `${PACKAGE_NAME}@${version}`], {
|
|
91
|
+
stdio: "inherit",
|
|
92
|
+
shell: process.platform === "win32",
|
|
93
|
+
});
|
|
94
|
+
child.on("error", () => resolve(false));
|
|
95
|
+
child.on("close", (code) => resolve(code === 0));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Starts the freshly installed Nou in place of this process. */
|
|
99
|
+
export function relaunch() {
|
|
100
|
+
const child = spawn(process.execPath, process.argv.slice(1).filter((a) => a !== "update"), { stdio: "inherit" });
|
|
101
|
+
child.on("close", (code) => process.exit(code ?? 0));
|
|
102
|
+
}
|