nou-cli 0.4.0 → 0.5.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 +18 -3
- package/dist/index.js +4 -5
- package/dist/src/env.js +3 -0
- package/dist/src/ui/App.js +170 -50
- package/dist/src/ui/Chat.js +92 -45
- package/dist/src/ui/ChatInput.js +11 -2
- package/dist/src/ui/CodeView.js +41 -22
- package/dist/src/ui/Editor.js +471 -99
- package/dist/src/ui/FileTree.js +64 -37
- package/dist/src/ui/Header.js +3 -2
- package/dist/src/ui/ProblemsView.js +2 -2
- package/dist/src/ui/clipboard.js +30 -0
- package/dist/src/ui/fast-output.js +250 -0
- package/dist/src/ui/mouse.js +4 -1
- package/dist/src/ui/render-options.js +11 -0
- package/dist/src/ui/scroll.js +30 -0
- package/package.json +3 -2
package/dist/src/ui/Editor.js
CHANGED
|
@@ -1,77 +1,276 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import path from "path";
|
|
3
4
|
import { Box, Text, useInput, usePaste } from "ink";
|
|
4
5
|
import { highlightLines } from "./text.js";
|
|
5
6
|
import { C, fileIcon } from "./theme.js";
|
|
6
7
|
import { SEVERITY_STYLE } from "./ProblemsView.js";
|
|
7
8
|
import { Pane } from "./Pane.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import { Scrollbar, topFor } from "./scroll.js";
|
|
10
|
+
import { copyToClipboard, readClipboard } from "./clipboard.js";
|
|
11
|
+
const TAB = " ";
|
|
12
|
+
const expand = (s) => s.replace(/\t/g, TAB);
|
|
13
|
+
/** Screen column of buffer column `col` on `line` (tabs take two cells). */
|
|
14
|
+
const screenCol = (line, col) => expand(line.slice(0, col)).length;
|
|
15
|
+
/** Buffer column at screen column `x` on `line`. */
|
|
16
|
+
function bufferCol(line, x) {
|
|
17
|
+
let cells = 0;
|
|
18
|
+
for (let i = 0; i < line.length; i++) {
|
|
19
|
+
cells += line[i] === "\t" ? TAB.length : 1;
|
|
20
|
+
if (cells > x)
|
|
21
|
+
return i;
|
|
22
|
+
}
|
|
23
|
+
return line.length;
|
|
24
|
+
}
|
|
25
|
+
const before = (a, b) => a.row < b.row || (a.row === b.row && a.col < b.col);
|
|
26
|
+
/** The selection as start and end positions, or null when nothing is selected. */
|
|
27
|
+
function selection(b) {
|
|
28
|
+
if (!b.anchor || (b.anchor.row === b.row && b.anchor.col === b.col))
|
|
29
|
+
return null;
|
|
30
|
+
const cursor = { row: b.row, col: b.col };
|
|
31
|
+
return before(b.anchor, cursor) ? { start: b.anchor, end: cursor } : { start: cursor, end: b.anchor };
|
|
32
|
+
}
|
|
33
|
+
function selectedText(b) {
|
|
34
|
+
const sel = selection(b);
|
|
35
|
+
if (!sel)
|
|
36
|
+
return "";
|
|
37
|
+
if (sel.start.row === sel.end.row)
|
|
38
|
+
return b.lines[sel.start.row].slice(sel.start.col, sel.end.col);
|
|
39
|
+
return [
|
|
40
|
+
b.lines[sel.start.row].slice(sel.start.col),
|
|
41
|
+
...b.lines.slice(sel.start.row + 1, sel.end.row),
|
|
42
|
+
b.lines[sel.end.row].slice(0, sel.end.col),
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
/** The buffer with the selection removed and the cursor where it began. */
|
|
46
|
+
function deleteSelection(b) {
|
|
47
|
+
const sel = selection(b);
|
|
48
|
+
if (!sel)
|
|
49
|
+
return { ...b, anchor: null };
|
|
50
|
+
const lines = [...b.lines];
|
|
51
|
+
const merged = lines[sel.start.row].slice(0, sel.start.col) + lines[sel.end.row].slice(sel.end.col);
|
|
52
|
+
lines.splice(sel.start.row, sel.end.row - sel.start.row + 1, merged);
|
|
53
|
+
return { lines, row: sel.start.row, col: sel.start.col, anchor: null };
|
|
54
|
+
}
|
|
55
|
+
/** Rows a line-wise command (indent, comment, move) applies to: the selected lines, or the cursor's. */
|
|
56
|
+
function selectedRows(b) {
|
|
57
|
+
const sel = selection(b);
|
|
58
|
+
if (!sel)
|
|
59
|
+
return [b.row, b.row];
|
|
60
|
+
// A selection ending at the very start of a line doesn't include that line.
|
|
61
|
+
return [sel.start.row, sel.end.col === 0 && sel.end.row > sel.start.row ? sel.end.row - 1 : sel.end.row];
|
|
62
|
+
}
|
|
63
|
+
const isWord = (ch) => !!ch && /\w/.test(ch);
|
|
64
|
+
/** Where Ctrl+← lands: the start of the previous word. */
|
|
65
|
+
function wordLeft(line, col) {
|
|
66
|
+
let i = col;
|
|
67
|
+
while (i > 0 && !isWord(line[i - 1]))
|
|
68
|
+
i--;
|
|
69
|
+
while (i > 0 && isWord(line[i - 1]))
|
|
70
|
+
i--;
|
|
71
|
+
return i;
|
|
72
|
+
}
|
|
73
|
+
/** Where Ctrl+→ lands: the end of the next word. */
|
|
74
|
+
function wordRight(line, col) {
|
|
75
|
+
let i = col;
|
|
76
|
+
while (i < line.length && !isWord(line[i]))
|
|
77
|
+
i++;
|
|
78
|
+
while (i < line.length && isWord(line[i]))
|
|
79
|
+
i++;
|
|
80
|
+
return i;
|
|
81
|
+
}
|
|
82
|
+
const LINE_COMMENTS = {
|
|
83
|
+
".ts": "//", ".tsx": "//", ".mts": "//", ".cts": "//", ".js": "//", ".jsx": "//", ".mjs": "//", ".cjs": "//",
|
|
84
|
+
".go": "//", ".rs": "//", ".java": "//", ".c": "//", ".h": "//", ".cpp": "//", ".cc": "//", ".hpp": "//",
|
|
85
|
+
".cs": "//", ".swift": "//", ".kt": "//", ".kts": "//", ".scala": "//", ".dart": "//", ".php": "//", ".jsonc": "//",
|
|
86
|
+
".py": "#", ".pyi": "#", ".rb": "#", ".sh": "#", ".bash": "#", ".zsh": "#", ".ps1": "#", ".yml": "#", ".yaml": "#",
|
|
87
|
+
".toml": "#", ".r": "#", ".pl": "#", ".ex": "#", ".exs": "#", ".dockerfile": "#", ".sql": "--", ".lua": "--", ".hs": "--",
|
|
88
|
+
};
|
|
89
|
+
const commentFor = (file) => LINE_COMMENTS[path.extname(file).toLowerCase()] ?? (path.basename(file).toLowerCase() === "dockerfile" ? "#" : null);
|
|
90
|
+
/** A text editor: arrows and the mouse to move, Shift to select, Ctrl+C/X/V clipboard, Ctrl+S save, Ctrl+Z undo, Esc leave. */
|
|
91
|
+
export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition }) {
|
|
11
92
|
const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
|
|
12
93
|
const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
|
|
94
|
+
const comment = useMemo(() => commentFor(filePath), [filePath]);
|
|
13
95
|
const [buf, setBuf] = useState(() => {
|
|
14
96
|
const lines = initial.replace(/\r\n/g, "\n").split("\n");
|
|
15
97
|
const row = Math.max(0, Math.min(lines.length - 1, (startLine ?? 1) - 1));
|
|
16
|
-
|
|
98
|
+
const col = startCol ? Math.min(lines[row].length, startCol - 1) : lines[row].match(/^\s*/)[0].length;
|
|
99
|
+
return { lines, row, col, anchor: null };
|
|
17
100
|
});
|
|
18
101
|
const [hover, setHover] = useState(null);
|
|
19
102
|
const [saved, setSaved] = useState(() => baseline.replace(/\r\n/g, "\n"));
|
|
20
103
|
const [confirmExit, setConfirmExit] = useState(false);
|
|
21
104
|
const [flash, setFlash] = useState(null);
|
|
105
|
+
const [prompt, setPrompt] = useState(null);
|
|
22
106
|
const undo = useRef([]);
|
|
23
107
|
const redo = useRef([]);
|
|
24
108
|
// Consecutive typing within a word is one undo step, like VS Code.
|
|
25
109
|
const lastEdit = useRef({ kind: "", time: 0 });
|
|
110
|
+
// The first line and column shown. Moving the cursor scrolls to keep it in view; the wheel and the
|
|
111
|
+
// scrollbar move the view on their own and leave the cursor where it is.
|
|
26
112
|
const view = useRef({ top: 0, left: 0 });
|
|
113
|
+
const reveal = useRef(true);
|
|
114
|
+
const [, rerender] = useState(0);
|
|
115
|
+
// Where a mouse drag started, to select from there.
|
|
116
|
+
const dragFrom = useRef(null);
|
|
27
117
|
const dirty = useMemo(() => buf.lines.join("\n") !== saved, [buf.lines, saved]);
|
|
28
|
-
|
|
118
|
+
const hasSelection = !!selection(buf);
|
|
119
|
+
useEffect(() => onState({ line: buf.row + 1, col: buf.col + 1, dirty, lines: buf.lines, selection: hasSelection }), [buf, dirty]);
|
|
29
120
|
useEffect(() => {
|
|
30
121
|
if (!flash)
|
|
31
122
|
return;
|
|
32
123
|
const t = setTimeout(() => setFlash(null), 1500);
|
|
33
124
|
return () => clearTimeout(t);
|
|
34
125
|
}, [flash]);
|
|
35
|
-
const edit = (fn, kind = "other") =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
undo.current.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
126
|
+
const edit = (fn, kind = "other") => {
|
|
127
|
+
reveal.current = true;
|
|
128
|
+
setBuf((b) => {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const grouped = kind !== "other" && kind === lastEdit.current.kind && now - lastEdit.current.time < 1000 && !b.anchor;
|
|
131
|
+
if (!grouped) {
|
|
132
|
+
undo.current.push(b);
|
|
133
|
+
if (undo.current.length > 500)
|
|
134
|
+
undo.current.shift();
|
|
135
|
+
}
|
|
136
|
+
redo.current = [];
|
|
137
|
+
lastEdit.current = { kind, time: now };
|
|
138
|
+
return fn(b);
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
const step = (from, to) => {
|
|
142
|
+
reveal.current = true;
|
|
143
|
+
setBuf((b) => {
|
|
144
|
+
const target = from.current.pop();
|
|
145
|
+
if (!target)
|
|
146
|
+
return b;
|
|
147
|
+
to.current.push(b);
|
|
148
|
+
lastEdit.current = { kind: "", time: 0 };
|
|
149
|
+
return target;
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
/** Inserts text at the cursor, replacing the selection. */
|
|
153
|
+
const insertInto = (b0, text) => {
|
|
154
|
+
const b = deleteSelection(b0);
|
|
56
155
|
const pieces = text.replace(/\r\n?/g, "\n").split("\n");
|
|
57
156
|
const line = b.lines[b.row];
|
|
58
|
-
const
|
|
59
|
-
const
|
|
157
|
+
const head = line.slice(0, b.col);
|
|
158
|
+
const tail = line.slice(b.col);
|
|
60
159
|
const lines = [...b.lines];
|
|
61
160
|
if (pieces.length === 1) {
|
|
62
|
-
lines[b.row] =
|
|
63
|
-
return { lines, row: b.row, col: b.col + text.length };
|
|
161
|
+
lines[b.row] = head + text + tail;
|
|
162
|
+
return { lines, row: b.row, col: b.col + text.length, anchor: null };
|
|
64
163
|
}
|
|
65
164
|
const last = pieces[pieces.length - 1];
|
|
66
|
-
lines.splice(b.row, 1,
|
|
67
|
-
return { lines, row: b.row + pieces.length - 1, col: last.length };
|
|
68
|
-
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
165
|
+
lines.splice(b.row, 1, head + pieces[0], ...pieces.slice(1, -1), last + tail);
|
|
166
|
+
return { lines, row: b.row + pieces.length - 1, col: last.length, anchor: null };
|
|
167
|
+
};
|
|
168
|
+
const insert = (text, kind = "other") => edit((b) => insertInto(b, text), kind);
|
|
169
|
+
/** Moves the cursor; with `select`, extends the selection instead of clearing it. */
|
|
170
|
+
const move = (fn, select = false) => {
|
|
171
|
+
reveal.current = true;
|
|
172
|
+
setBuf((b) => {
|
|
173
|
+
const next = { ...b, ...fn(b) };
|
|
174
|
+
next.row = Math.max(0, Math.min(next.lines.length - 1, next.row));
|
|
175
|
+
next.col = Math.max(0, Math.min(next.lines[next.row].length, next.col));
|
|
176
|
+
next.anchor = select ? b.anchor ?? { row: b.row, col: b.col } : null;
|
|
177
|
+
return next;
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
/** Applies `fn` to each line a line-wise command covers, keeping the selection over the same lines. */
|
|
181
|
+
const eachLine = (fn) => edit((b) => {
|
|
182
|
+
const [first, last] = selectedRows(b);
|
|
183
|
+
const lines = [...b.lines];
|
|
184
|
+
for (let r = first; r <= last; r++)
|
|
185
|
+
lines[r] = fn(lines[r], r);
|
|
186
|
+
const shift = (p) => ({ row: p.row, col: Math.max(0, Math.min(lines[p.row].length, p.col + lines[p.row].length - b.lines[p.row].length)) });
|
|
187
|
+
const cursor = shift({ row: b.row, col: b.col });
|
|
188
|
+
return { lines, ...cursor, anchor: b.anchor ? shift(b.anchor) : null };
|
|
189
|
+
});
|
|
190
|
+
const toggleComment = () => {
|
|
191
|
+
if (!comment)
|
|
192
|
+
return setFlash("No line comments for this file type");
|
|
193
|
+
const [first, last] = selectedRows(buf);
|
|
194
|
+
const target = buf.lines.slice(first, last + 1).filter((l) => l.trim());
|
|
195
|
+
const uncomment = target.length > 0 && target.every((l) => l.trimStart().startsWith(comment));
|
|
196
|
+
const indent = Math.min(...target.map((l) => l.match(/^\s*/)[0].length), Infinity);
|
|
197
|
+
const marker = new RegExp(`^(\\s*)${comment.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")} ?`);
|
|
198
|
+
eachLine((line) => {
|
|
199
|
+
if (!line.trim())
|
|
200
|
+
return line;
|
|
201
|
+
if (uncomment)
|
|
202
|
+
return line.replace(marker, "$1");
|
|
203
|
+
return `${line.slice(0, indent)}${comment} ${line.slice(indent)}`;
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
const moveLines = (dir) => edit((b) => {
|
|
207
|
+
const [first, last] = selectedRows(b);
|
|
208
|
+
if ((dir < 0 && first === 0) || (dir > 0 && last === b.lines.length - 1))
|
|
209
|
+
return b;
|
|
210
|
+
const lines = [...b.lines];
|
|
211
|
+
const block = lines.splice(first, last - first + 1);
|
|
212
|
+
lines.splice(first + dir, 0, ...block);
|
|
213
|
+
return { lines, row: b.row + dir, col: b.col, anchor: b.anchor ? { row: b.anchor.row + dir, col: b.anchor.col } : null };
|
|
214
|
+
});
|
|
215
|
+
const duplicate = () => edit((b) => {
|
|
216
|
+
const [first, last] = selectedRows(b);
|
|
217
|
+
const lines = [...b.lines];
|
|
218
|
+
const block = lines.slice(first, last + 1);
|
|
219
|
+
lines.splice(last + 1, 0, ...block);
|
|
220
|
+
const n = block.length;
|
|
221
|
+
return { lines, row: b.row + n, col: b.col, anchor: b.anchor ? { row: b.anchor.row + n, col: b.anchor.col } : null };
|
|
74
222
|
});
|
|
223
|
+
const copy = async (cut) => {
|
|
224
|
+
// Nothing selected: copy (or cut) the whole line, like VS Code.
|
|
225
|
+
const text = hasSelection ? selectedText(buf) : `${buf.lines[buf.row]}\n`;
|
|
226
|
+
const ok = await copyToClipboard(text);
|
|
227
|
+
setFlash(ok ? (cut ? "Cut ✓" : "Copied ✓") : "Couldn't reach the clipboard");
|
|
228
|
+
if (!cut)
|
|
229
|
+
return;
|
|
230
|
+
edit((b) => {
|
|
231
|
+
if (selection(b))
|
|
232
|
+
return deleteSelection(b);
|
|
233
|
+
const lines = [...b.lines];
|
|
234
|
+
lines.splice(b.row, 1);
|
|
235
|
+
if (!lines.length)
|
|
236
|
+
lines.push("");
|
|
237
|
+
const row = Math.min(b.row, lines.length - 1);
|
|
238
|
+
return { lines, row, col: Math.min(b.col, lines[row].length), anchor: null };
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
const paste = async () => {
|
|
242
|
+
const text = await readClipboard();
|
|
243
|
+
if (text === null)
|
|
244
|
+
return setFlash("Couldn't read the clipboard: paste with your terminal (Ctrl+Shift+V / Cmd+V)");
|
|
245
|
+
if (text)
|
|
246
|
+
insert(text);
|
|
247
|
+
};
|
|
248
|
+
/** Finds `query` after (or, going back, before) the cursor, wrapping around, and selects it. */
|
|
249
|
+
const find = (query, back = false, from = buf) => {
|
|
250
|
+
if (!query)
|
|
251
|
+
return;
|
|
252
|
+
const q = query.toLowerCase();
|
|
253
|
+
const n = from.lines.length;
|
|
254
|
+
const sel = selection(from);
|
|
255
|
+
const origin = back ? sel?.start ?? from : sel ? sel.end : from;
|
|
256
|
+
for (let i = 0; i <= n; i++) {
|
|
257
|
+
const r = back ? (origin.row - i + n) % n : (origin.row + i) % n;
|
|
258
|
+
const line = from.lines[r].toLowerCase();
|
|
259
|
+
let at = -1;
|
|
260
|
+
if (back)
|
|
261
|
+
at = i === 0 ? line.lastIndexOf(q, Math.max(0, origin.col - 1)) : line.lastIndexOf(q);
|
|
262
|
+
else
|
|
263
|
+
at = line.indexOf(q, i === 0 ? origin.col : 0);
|
|
264
|
+
if (i === 0 && back && at >= origin.col)
|
|
265
|
+
at = -1;
|
|
266
|
+
if (at >= 0) {
|
|
267
|
+
reveal.current = true;
|
|
268
|
+
setBuf((b) => ({ ...b, row: r, col: at + q.length, anchor: { row: r, col: at } }));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
setFlash(`No "${query}" in this file`);
|
|
273
|
+
};
|
|
75
274
|
const save = (auto = false) => {
|
|
76
275
|
const text = buf.lines.join("\n");
|
|
77
276
|
onSave(text.replace(/\n/g, eol));
|
|
@@ -91,11 +290,54 @@ export function Editor({ width, height, focused, filePath, initial, baseline, st
|
|
|
91
290
|
}, [focused]);
|
|
92
291
|
const position = () => ({ text: buf.lines.join("\n"), line: buf.row + 1, col: buf.col + 1 });
|
|
93
292
|
const rows = Math.max(1, height - 4);
|
|
293
|
+
const gutter = String(buf.lines.length).length + 2;
|
|
294
|
+
const textWidth = Math.max(10, width - 3 - gutter);
|
|
295
|
+
const maxTop = Math.max(0, buf.lines.length - rows);
|
|
296
|
+
// Wheel and scrollbar: move the view, not the cursor.
|
|
94
297
|
useEffect(() => {
|
|
95
|
-
if (wheel)
|
|
96
|
-
|
|
298
|
+
if (!wheel)
|
|
299
|
+
return;
|
|
300
|
+
reveal.current = false;
|
|
301
|
+
view.current.top = Math.max(0, Math.min(maxTop, view.current.top + wheel.lines));
|
|
302
|
+
view.current.left = Math.max(0, view.current.left + wheel.columns * 2);
|
|
303
|
+
rerender((n) => n + 1);
|
|
97
304
|
}, [wheel?.nonce]);
|
|
98
|
-
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
if (!scrollTo)
|
|
307
|
+
return;
|
|
308
|
+
reveal.current = false;
|
|
309
|
+
view.current.top = topFor(scrollTo.fraction, buf.lines.length, rows);
|
|
310
|
+
rerender((n) => n + 1);
|
|
311
|
+
}, [scrollTo?.nonce]);
|
|
312
|
+
// Click to put the cursor there; drag to select.
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (!mouse)
|
|
315
|
+
return;
|
|
316
|
+
if (mouse.kind === "release") {
|
|
317
|
+
dragFrom.current = null;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const v = view.current;
|
|
321
|
+
const row = Math.max(0, Math.min(buf.lines.length - 1, v.top + mouse.y));
|
|
322
|
+
const col = bufferCol(buf.lines[row], Math.max(0, mouse.x - gutter) + v.left);
|
|
323
|
+
setHover(null);
|
|
324
|
+
setPrompt((p) => (p?.kind === "line" ? null : p));
|
|
325
|
+
setBuf((b) => {
|
|
326
|
+
if (mouse.kind === "press") {
|
|
327
|
+
dragFrom.current = { row, col };
|
|
328
|
+
return { ...b, row, col, anchor: null };
|
|
329
|
+
}
|
|
330
|
+
return { ...b, row, col, anchor: dragFrom.current ?? b.anchor ?? { row: b.row, col: b.col } };
|
|
331
|
+
});
|
|
332
|
+
// Dragging past the top or bottom edge scrolls.
|
|
333
|
+
if (mouse.kind === "drag" && (mouse.y < 0 || mouse.y >= rows))
|
|
334
|
+
reveal.current = true;
|
|
335
|
+
}, [mouse?.nonce]);
|
|
336
|
+
usePaste((text) => {
|
|
337
|
+
if (prompt)
|
|
338
|
+
return setPrompt({ ...prompt, text: prompt.text + text.replace(/\s+/g, " ") });
|
|
339
|
+
insert(text);
|
|
340
|
+
}, { isActive: focused });
|
|
99
341
|
useInput((input, key) => {
|
|
100
342
|
if (confirmExit) {
|
|
101
343
|
if (key.escape)
|
|
@@ -110,14 +352,65 @@ export function Editor({ width, height, focused, filePath, initial, baseline, st
|
|
|
110
352
|
}
|
|
111
353
|
if (hover)
|
|
112
354
|
setHover(null);
|
|
113
|
-
|
|
114
|
-
|
|
355
|
+
// Find (Ctrl+F) and go to line (Ctrl+L) take the keyboard while open.
|
|
356
|
+
if (prompt) {
|
|
357
|
+
if (key.escape)
|
|
358
|
+
return setPrompt(null);
|
|
359
|
+
if (key.return) {
|
|
360
|
+
if (prompt.kind === "find")
|
|
361
|
+
return find(prompt.text, key.shift);
|
|
362
|
+
const n = Number(prompt.text);
|
|
363
|
+
if (n > 0)
|
|
364
|
+
move(() => ({ row: n - 1, col: buf.lines[n - 1]?.match(/^\s*/)?.[0].length ?? 0 }));
|
|
365
|
+
return setPrompt(null);
|
|
366
|
+
}
|
|
367
|
+
if (prompt.kind === "find" && (key.downArrow || key.upArrow))
|
|
368
|
+
return find(prompt.text, key.upArrow);
|
|
369
|
+
if (key.backspace || key.delete)
|
|
370
|
+
return setPrompt({ ...prompt, text: prompt.text.slice(0, -1) });
|
|
371
|
+
if (key.ctrl && input === "f")
|
|
372
|
+
return find(prompt.text);
|
|
373
|
+
if (input && !key.ctrl && !key.meta && (prompt.kind === "find" || /^\d+$/.test(input))) {
|
|
374
|
+
const text = prompt.text + input;
|
|
375
|
+
setPrompt({ ...prompt, text });
|
|
376
|
+
// Find as you type, from where the search started.
|
|
377
|
+
if (prompt.kind === "find")
|
|
378
|
+
find(text, false, { ...buf, col: selection(buf)?.start.col ?? buf.col, anchor: null });
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (key.ctrl && input === "c") {
|
|
383
|
+
// With a selection Ctrl+C copies; otherwise the app handles it (press twice to quit).
|
|
384
|
+
if (hasSelection)
|
|
385
|
+
void copy(false);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (key.ctrl && input === "x")
|
|
389
|
+
return void copy(true);
|
|
390
|
+
if (key.ctrl && input === "v")
|
|
391
|
+
return void paste();
|
|
392
|
+
if (key.ctrl && input === "a") {
|
|
393
|
+
reveal.current = true;
|
|
394
|
+
const last = buf.lines.length - 1;
|
|
395
|
+
return setBuf((b) => ({ ...b, row: last, col: b.lines[last].length, anchor: { row: 0, col: 0 } }));
|
|
396
|
+
}
|
|
115
397
|
if (key.ctrl && input === "s")
|
|
116
398
|
return save();
|
|
117
399
|
if (key.ctrl && input === "z")
|
|
118
400
|
return step(undo, redo);
|
|
119
401
|
if (key.ctrl && input === "y")
|
|
120
402
|
return step(redo, undo);
|
|
403
|
+
if (key.ctrl && input === "f") {
|
|
404
|
+
const word = selectedText(buf);
|
|
405
|
+
return setPrompt({ kind: "find", text: word.includes("\n") ? "" : word });
|
|
406
|
+
}
|
|
407
|
+
if (key.ctrl && input === "l")
|
|
408
|
+
return setPrompt({ kind: "line", text: "" });
|
|
409
|
+
if (key.ctrl && input === "d")
|
|
410
|
+
return duplicate();
|
|
411
|
+
// Ctrl+/ arrives as Ctrl+_ (0x1f) in most terminals.
|
|
412
|
+
if ((key.ctrl && (input === "/" || input === "_")) || input === "\x1f")
|
|
413
|
+
return toggleComment();
|
|
121
414
|
if (key.ctrl && input === "k") {
|
|
122
415
|
const { text, line, col } = position();
|
|
123
416
|
setFlash("…");
|
|
@@ -131,108 +424,187 @@ export function Editor({ width, height, focused, filePath, initial, baseline, st
|
|
|
131
424
|
const { text, line, col } = position();
|
|
132
425
|
return onDefinition(text, line, col);
|
|
133
426
|
}
|
|
427
|
+
if (key.ctrl && input === "w") {
|
|
428
|
+
return edit((b) => {
|
|
429
|
+
if (selection(b))
|
|
430
|
+
return deleteSelection(b);
|
|
431
|
+
const line = b.lines[b.row];
|
|
432
|
+
const to = wordLeft(line, b.col);
|
|
433
|
+
const lines = [...b.lines];
|
|
434
|
+
lines[b.row] = line.slice(0, to) + line.slice(b.col);
|
|
435
|
+
return { ...b, lines, col: to, anchor: null };
|
|
436
|
+
}, "delete");
|
|
437
|
+
}
|
|
134
438
|
if (key.escape) {
|
|
439
|
+
if (hasSelection)
|
|
440
|
+
return setBuf((b) => ({ ...b, anchor: null }));
|
|
135
441
|
if (dirty && autoSave !== "off") {
|
|
136
442
|
save(true);
|
|
137
443
|
return onExit();
|
|
138
444
|
}
|
|
139
445
|
return dirty ? setConfirmExit(true) : onExit();
|
|
140
446
|
}
|
|
447
|
+
const select = key.shift;
|
|
448
|
+
const word = key.ctrl || key.meta;
|
|
449
|
+
if (key.meta && !key.shift && (key.upArrow || key.downArrow))
|
|
450
|
+
return moveLines(key.upArrow ? -1 : 1);
|
|
141
451
|
if (key.upArrow)
|
|
142
|
-
return move((b) => ({ row: b.row - 1 }));
|
|
452
|
+
return move((b) => ({ row: b.row - 1, col: bufferCol(b.lines[b.row - 1] ?? "", screenCol(b.lines[b.row], b.col)) }), select);
|
|
143
453
|
if (key.downArrow)
|
|
144
|
-
return move((b) => ({ row: b.row + 1 }));
|
|
454
|
+
return move((b) => ({ row: b.row + 1, col: bufferCol(b.lines[b.row + 1] ?? "", screenCol(b.lines[b.row], b.col)) }), select);
|
|
145
455
|
if (key.leftArrow) {
|
|
146
|
-
|
|
456
|
+
const sel = selection(buf);
|
|
457
|
+
if (sel && !select && !word)
|
|
458
|
+
return move(() => sel.start);
|
|
459
|
+
return move((b) => (word ? { col: wordLeft(b.lines[b.row], b.col) }
|
|
460
|
+
: b.col > 0 ? { col: b.col - 1 } : b.row > 0 ? { row: b.row - 1, col: b.lines[b.row - 1].length } : {}), select);
|
|
147
461
|
}
|
|
148
462
|
if (key.rightArrow) {
|
|
149
|
-
|
|
463
|
+
const sel = selection(buf);
|
|
464
|
+
if (sel && !select && !word)
|
|
465
|
+
return move(() => sel.end);
|
|
466
|
+
return move((b) => (word ? { col: wordRight(b.lines[b.row], b.col) }
|
|
467
|
+
: b.col < b.lines[b.row].length ? { col: b.col + 1 } : b.row < b.lines.length - 1 ? { row: b.row + 1, col: 0 } : {}), select);
|
|
468
|
+
}
|
|
469
|
+
if (key.home) {
|
|
470
|
+
if (key.ctrl)
|
|
471
|
+
return move(() => ({ row: 0, col: 0 }), select);
|
|
472
|
+
return move((b) => {
|
|
473
|
+
const indent = b.lines[b.row].match(/^\s*/)[0].length;
|
|
474
|
+
return { col: b.col === indent ? 0 : indent };
|
|
475
|
+
}, select);
|
|
476
|
+
}
|
|
477
|
+
if (key.end) {
|
|
478
|
+
if (key.ctrl)
|
|
479
|
+
return move((b) => ({ row: b.lines.length - 1, col: b.lines[b.lines.length - 1].length }), select);
|
|
480
|
+
return move((b) => ({ col: b.lines[b.row].length }), select);
|
|
150
481
|
}
|
|
151
|
-
if (key.home)
|
|
152
|
-
return move((b) => ({ col: b.lines[b.row].match(/^\s*/)[0].length === b.col ? 0 : b.lines[b.row].match(/^\s*/)[0].length }));
|
|
153
|
-
if (key.end)
|
|
154
|
-
return move((b) => ({ col: b.lines[b.row].length }));
|
|
155
482
|
if (key.pageUp)
|
|
156
|
-
return move((b) => ({ row: b.row - rows }));
|
|
483
|
+
return move((b) => ({ row: b.row - rows }), select);
|
|
157
484
|
if (key.pageDown)
|
|
158
|
-
return move((b) => ({ row: b.row + rows }));
|
|
485
|
+
return move((b) => ({ row: b.row + rows }), select);
|
|
159
486
|
if (key.return) {
|
|
160
|
-
|
|
161
|
-
|
|
487
|
+
// Keep the indent; one level more after an opening bracket (or a colon, in Python).
|
|
488
|
+
const head = buf.lines[buf.row].slice(0, buf.col);
|
|
489
|
+
const indent = head.match(/^\s*/)[0];
|
|
490
|
+
const opens = /[{[(]\s*$/.test(head) || (/\.pyi?$/.test(filePath) && /:\s*$/.test(head));
|
|
491
|
+
const closes = /^\s*[}\])]/.test(buf.lines[buf.row].slice(buf.col));
|
|
492
|
+
if (opens && closes) {
|
|
493
|
+
// Between brackets: open an indented line and put the closing bracket below it.
|
|
494
|
+
insert(`\n${indent}${indentUnit}\n${indent}`);
|
|
495
|
+
return move((b) => ({ row: b.row - 1, col: indent.length + indentUnit.length }));
|
|
496
|
+
}
|
|
497
|
+
return insert(`\n${indent}${opens ? indentUnit : ""}`);
|
|
162
498
|
}
|
|
163
|
-
if (key.tab)
|
|
499
|
+
if (key.tab) {
|
|
500
|
+
// With a selection, or Shift+Tab: indent or outdent whole lines.
|
|
501
|
+
if (key.shift)
|
|
502
|
+
return eachLine((line) => line.replace(new RegExp(`^(\\t| {1,${indentUnit === "\t" ? 2 : indentUnit.length}})`), ""));
|
|
503
|
+
if (hasSelection)
|
|
504
|
+
return eachLine((line) => (line ? indentUnit + line : line));
|
|
164
505
|
return insert(indentUnit);
|
|
506
|
+
}
|
|
165
507
|
if (key.backspace) {
|
|
166
508
|
return edit((b) => {
|
|
509
|
+
if (selection(b))
|
|
510
|
+
return deleteSelection(b);
|
|
167
511
|
const lines = [...b.lines];
|
|
512
|
+
const line = lines[b.row];
|
|
168
513
|
if (b.col > 0) {
|
|
169
|
-
const
|
|
170
|
-
lines[b.row] = line.slice(0,
|
|
171
|
-
return { lines, row: b.row, col:
|
|
514
|
+
const to = word ? wordLeft(line, b.col) : b.col - 1;
|
|
515
|
+
lines[b.row] = line.slice(0, to) + line.slice(b.col);
|
|
516
|
+
return { lines, row: b.row, col: to, anchor: null };
|
|
172
517
|
}
|
|
173
518
|
if (b.row === 0)
|
|
174
519
|
return b;
|
|
175
520
|
const prev = lines[b.row - 1];
|
|
176
|
-
lines.splice(b.row - 1, 2, prev +
|
|
177
|
-
return { lines, row: b.row - 1, col: prev.length };
|
|
521
|
+
lines.splice(b.row - 1, 2, prev + line);
|
|
522
|
+
return { lines, row: b.row - 1, col: prev.length, anchor: null };
|
|
178
523
|
}, "delete");
|
|
179
524
|
}
|
|
180
525
|
if (key.delete) {
|
|
181
526
|
return edit((b) => {
|
|
527
|
+
if (selection(b))
|
|
528
|
+
return deleteSelection(b);
|
|
182
529
|
const lines = [...b.lines];
|
|
183
530
|
const line = lines[b.row];
|
|
184
531
|
if (b.col < line.length)
|
|
185
|
-
lines[b.row] = line.slice(0, b.col) + line.slice(b.col + 1);
|
|
532
|
+
lines[b.row] = line.slice(0, b.col) + line.slice(word ? wordRight(line, b.col) : b.col + 1);
|
|
186
533
|
else if (b.row < lines.length - 1)
|
|
187
534
|
lines.splice(b.row, 2, line + lines[b.row + 1]);
|
|
188
|
-
return { ...b, lines };
|
|
535
|
+
return { ...b, lines, anchor: null };
|
|
189
536
|
});
|
|
190
537
|
}
|
|
191
538
|
// Letters/digits group into one undo step per word; spaces and punctuation start a new one.
|
|
192
539
|
if (input && !key.ctrl && !key.meta)
|
|
193
540
|
insert(input, /^\w+$/.test(input) ? "type" : "other");
|
|
194
541
|
}, { isActive: focused });
|
|
195
|
-
// Keep the cursor on screen.
|
|
196
|
-
const gutter = String(buf.lines.length).length + 2;
|
|
197
|
-
const textWidth = Math.max(10, width - 2 - gutter);
|
|
542
|
+
// Keep the cursor on screen after it moves.
|
|
198
543
|
const v = view.current;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
544
|
+
const cursorX = screenCol(buf.lines[buf.row], buf.col);
|
|
545
|
+
if (reveal.current) {
|
|
546
|
+
if (buf.row < v.top)
|
|
547
|
+
v.top = buf.row;
|
|
548
|
+
if (buf.row >= v.top + rows)
|
|
549
|
+
v.top = buf.row - rows + 1;
|
|
550
|
+
if (cursorX < v.left)
|
|
551
|
+
v.left = Math.max(0, cursorX - 8);
|
|
552
|
+
if (cursorX >= v.left + textWidth)
|
|
553
|
+
v.left = cursorX - textWidth + 8;
|
|
554
|
+
}
|
|
555
|
+
v.top = Math.max(0, Math.min(v.top, maxTop));
|
|
208
556
|
const visible = buf.lines.slice(v.top, v.top + rows);
|
|
209
557
|
const colored = useMemo(
|
|
210
|
-
// Scrolled sideways: every line shifts by the same amount
|
|
211
|
-
//
|
|
558
|
+
// Scrolled sideways: every line shifts by the same amount (plain text, since slicing highlighted
|
|
559
|
+
// text could cut a color code in half).
|
|
212
560
|
() => (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]);
|
|
213
561
|
const { icon, color } = fileIcon(filePath);
|
|
214
|
-
const byLine =
|
|
215
|
-
|
|
216
|
-
|
|
562
|
+
const byLine = useMemo(() => {
|
|
563
|
+
const map = new Map();
|
|
564
|
+
for (const d of problems)
|
|
565
|
+
map.set(d.line, [...(map.get(d.line) ?? []), d]);
|
|
566
|
+
return map;
|
|
567
|
+
}, [problems]);
|
|
568
|
+
const sel = selection(buf);
|
|
217
569
|
const cursorProblem = byLine.get(buf.row + 1)?.[0];
|
|
570
|
+
const selectedCount = sel ? selectedText(buf).length : 0;
|
|
218
571
|
const status = confirmExit
|
|
219
572
|
? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
|
|
220
|
-
:
|
|
221
|
-
?
|
|
222
|
-
:
|
|
223
|
-
|
|
573
|
+
: prompt
|
|
574
|
+
? prompt.kind === "find" ? `Find: ${prompt.text}▏ Enter/↓ next · Shift+Enter/↑ previous · Esc close` : `Go to line: ${prompt.text}▏ Enter go · Esc cancel`
|
|
575
|
+
: flash ?? (cursorProblem
|
|
576
|
+
? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
|
|
577
|
+
: `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""} Ctrl+F find · Ctrl+/ comment · Esc done`);
|
|
578
|
+
const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
|
|
224
579
|
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
580
|
+
const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
|
|
581
|
+
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 " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
|
|
582
|
+
const n = v.top + i;
|
|
583
|
+
const isCursorLine = n === buf.row;
|
|
584
|
+
const problem = byLine.get(n + 1)?.[0];
|
|
585
|
+
const mark = problem ? SEVERITY_STYLE[problem.severity] : null;
|
|
586
|
+
const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
|
|
587
|
+
const inSelection = sel && n >= sel.start.row && n <= sel.end.row;
|
|
588
|
+
if (!isCursorLine && !inSelection) {
|
|
589
|
+
return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
|
|
590
|
+
}
|
|
591
|
+
// Lines with the cursor or a selection show plain text, so both can be drawn exactly.
|
|
592
|
+
const shown = expand(line).slice(v.left, v.left + textWidth);
|
|
593
|
+
const from = inSelection ? Math.max(0, (n === sel.start.row ? screenCol(line, sel.start.col) : 0) - v.left) : 0;
|
|
594
|
+
const to = inSelection ? Math.max(0, (n === sel.end.row ? screenCol(line, sel.end.col) : expand(line).length + 1) - v.left) : 0;
|
|
595
|
+
const at = isCursorLine ? cursorX - v.left : -1;
|
|
596
|
+
const cells = [];
|
|
597
|
+
for (let x = 0; x < Math.max(shown.length, at + 1, Math.min(to, textWidth)); x++) {
|
|
598
|
+
const ch = shown[x] ?? " ";
|
|
599
|
+
const cell = { text: ch, selected: x >= from && x < to, cursor: x === at && focused };
|
|
600
|
+
const last = cells[cells.length - 1];
|
|
601
|
+
if (last && !cell.cursor && !last.cursor && last.selected === cell.selected)
|
|
602
|
+
last.text += ch;
|
|
603
|
+
else
|
|
604
|
+
cells.push(cell);
|
|
605
|
+
}
|
|
606
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [num, cells.map((c, j) => c.cursor
|
|
607
|
+
? _jsx(Text, { inverse: true, color: C.green, children: c.text }, j)
|
|
608
|
+
: _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j))] }, n));
|
|
609
|
+
}) }), _jsx(Scrollbar, { total: buf.lines.length, top: v.top, rows: rows, focused: focused })] }), 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)) })), _jsx(Text, { wrap: "truncate-end", color: statusColor, children: ` ${status}` })] }));
|
|
610
|
+
});
|