nou-cli 0.4.0 → 0.5.1
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 +182 -51
- 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 +487 -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 +9 -4
- 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 };
|
|
74
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 };
|
|
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,70 @@ 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; double-click to select a word; drag to select.
|
|
313
|
+
const lastMouse = useRef(mouse?.[mouse.length - 1]?.seq ?? 0); // events from before the editor opened are done
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
for (const event of mouse ?? []) {
|
|
316
|
+
if (event.seq <= lastMouse.current)
|
|
317
|
+
continue;
|
|
318
|
+
lastMouse.current = event.seq;
|
|
319
|
+
handleMouse(event);
|
|
320
|
+
}
|
|
321
|
+
}, [mouse]);
|
|
322
|
+
const handleMouse = (mouse) => {
|
|
323
|
+
if (mouse.kind === "release") {
|
|
324
|
+
dragFrom.current = null;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const v = view.current;
|
|
328
|
+
const row = Math.max(0, Math.min(buf.lines.length - 1, v.top + mouse.y));
|
|
329
|
+
const col = bufferCol(buf.lines[row], Math.max(0, mouse.x - gutter) + v.left);
|
|
330
|
+
setHover(null);
|
|
331
|
+
setPrompt((p) => (p?.kind === "line" ? null : p));
|
|
332
|
+
setBuf((b) => {
|
|
333
|
+
if (mouse.kind === "press") {
|
|
334
|
+
dragFrom.current = { row, col };
|
|
335
|
+
return { ...b, row, col, anchor: null };
|
|
336
|
+
}
|
|
337
|
+
if (mouse.kind === "double") {
|
|
338
|
+
// Double-click selects the word under the pointer.
|
|
339
|
+
const line = b.lines[row];
|
|
340
|
+
const at = isWord(line[col]) ? col : isWord(line[col - 1]) ? col - 1 : -1;
|
|
341
|
+
dragFrom.current = null;
|
|
342
|
+
if (at < 0)
|
|
343
|
+
return { ...b, row, col, anchor: null };
|
|
344
|
+
return { ...b, row, col: wordRight(line, at), anchor: { row, col: wordLeft(line, at + 1) } };
|
|
345
|
+
}
|
|
346
|
+
return { ...b, row, col, anchor: dragFrom.current ?? b.anchor ?? { row: b.row, col: b.col } };
|
|
347
|
+
});
|
|
348
|
+
// Dragging past the top or bottom edge scrolls.
|
|
349
|
+
if (mouse.kind === "drag" && (mouse.y < 0 || mouse.y >= rows))
|
|
350
|
+
reveal.current = true;
|
|
351
|
+
};
|
|
352
|
+
usePaste((text) => {
|
|
353
|
+
if (prompt)
|
|
354
|
+
return setPrompt({ ...prompt, text: prompt.text + text.replace(/\s+/g, " ") });
|
|
355
|
+
insert(text);
|
|
356
|
+
}, { isActive: focused });
|
|
99
357
|
useInput((input, key) => {
|
|
100
358
|
if (confirmExit) {
|
|
101
359
|
if (key.escape)
|
|
@@ -110,14 +368,65 @@ export function Editor({ width, height, focused, filePath, initial, baseline, st
|
|
|
110
368
|
}
|
|
111
369
|
if (hover)
|
|
112
370
|
setHover(null);
|
|
113
|
-
|
|
114
|
-
|
|
371
|
+
// Find (Ctrl+F) and go to line (Ctrl+L) take the keyboard while open.
|
|
372
|
+
if (prompt) {
|
|
373
|
+
if (key.escape)
|
|
374
|
+
return setPrompt(null);
|
|
375
|
+
if (key.return) {
|
|
376
|
+
if (prompt.kind === "find")
|
|
377
|
+
return find(prompt.text, key.shift);
|
|
378
|
+
const n = Number(prompt.text);
|
|
379
|
+
if (n > 0)
|
|
380
|
+
move(() => ({ row: n - 1, col: buf.lines[n - 1]?.match(/^\s*/)?.[0].length ?? 0 }));
|
|
381
|
+
return setPrompt(null);
|
|
382
|
+
}
|
|
383
|
+
if (prompt.kind === "find" && (key.downArrow || key.upArrow))
|
|
384
|
+
return find(prompt.text, key.upArrow);
|
|
385
|
+
if (key.backspace || key.delete)
|
|
386
|
+
return setPrompt({ ...prompt, text: prompt.text.slice(0, -1) });
|
|
387
|
+
if (key.ctrl && input === "f")
|
|
388
|
+
return find(prompt.text);
|
|
389
|
+
if (input && !key.ctrl && !key.meta && (prompt.kind === "find" || /^\d+$/.test(input))) {
|
|
390
|
+
const text = prompt.text + input;
|
|
391
|
+
setPrompt({ ...prompt, text });
|
|
392
|
+
// Find as you type, from where the search started.
|
|
393
|
+
if (prompt.kind === "find")
|
|
394
|
+
find(text, false, { ...buf, col: selection(buf)?.start.col ?? buf.col, anchor: null });
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (key.ctrl && input === "c") {
|
|
399
|
+
// With a selection Ctrl+C copies; otherwise the app handles it (press twice to quit).
|
|
400
|
+
if (hasSelection)
|
|
401
|
+
void copy(false);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (key.ctrl && input === "x")
|
|
405
|
+
return void copy(true);
|
|
406
|
+
if (key.ctrl && input === "v")
|
|
407
|
+
return void paste();
|
|
408
|
+
if (key.ctrl && input === "a") {
|
|
409
|
+
reveal.current = true;
|
|
410
|
+
const last = buf.lines.length - 1;
|
|
411
|
+
return setBuf((b) => ({ ...b, row: last, col: b.lines[last].length, anchor: { row: 0, col: 0 } }));
|
|
412
|
+
}
|
|
115
413
|
if (key.ctrl && input === "s")
|
|
116
414
|
return save();
|
|
117
415
|
if (key.ctrl && input === "z")
|
|
118
416
|
return step(undo, redo);
|
|
119
417
|
if (key.ctrl && input === "y")
|
|
120
418
|
return step(redo, undo);
|
|
419
|
+
if (key.ctrl && input === "f") {
|
|
420
|
+
const word = selectedText(buf);
|
|
421
|
+
return setPrompt({ kind: "find", text: word.includes("\n") ? "" : word });
|
|
422
|
+
}
|
|
423
|
+
if (key.ctrl && input === "l")
|
|
424
|
+
return setPrompt({ kind: "line", text: "" });
|
|
425
|
+
if (key.ctrl && input === "d")
|
|
426
|
+
return duplicate();
|
|
427
|
+
// Ctrl+/ arrives as Ctrl+_ (0x1f) in most terminals.
|
|
428
|
+
if ((key.ctrl && (input === "/" || input === "_")) || input === "\x1f")
|
|
429
|
+
return toggleComment();
|
|
121
430
|
if (key.ctrl && input === "k") {
|
|
122
431
|
const { text, line, col } = position();
|
|
123
432
|
setFlash("…");
|
|
@@ -131,108 +440,187 @@ export function Editor({ width, height, focused, filePath, initial, baseline, st
|
|
|
131
440
|
const { text, line, col } = position();
|
|
132
441
|
return onDefinition(text, line, col);
|
|
133
442
|
}
|
|
443
|
+
if (key.ctrl && input === "w") {
|
|
444
|
+
return edit((b) => {
|
|
445
|
+
if (selection(b))
|
|
446
|
+
return deleteSelection(b);
|
|
447
|
+
const line = b.lines[b.row];
|
|
448
|
+
const to = wordLeft(line, b.col);
|
|
449
|
+
const lines = [...b.lines];
|
|
450
|
+
lines[b.row] = line.slice(0, to) + line.slice(b.col);
|
|
451
|
+
return { ...b, lines, col: to, anchor: null };
|
|
452
|
+
}, "delete");
|
|
453
|
+
}
|
|
134
454
|
if (key.escape) {
|
|
455
|
+
if (hasSelection)
|
|
456
|
+
return setBuf((b) => ({ ...b, anchor: null }));
|
|
135
457
|
if (dirty && autoSave !== "off") {
|
|
136
458
|
save(true);
|
|
137
459
|
return onExit();
|
|
138
460
|
}
|
|
139
461
|
return dirty ? setConfirmExit(true) : onExit();
|
|
140
462
|
}
|
|
463
|
+
const select = key.shift;
|
|
464
|
+
const word = key.ctrl || key.meta;
|
|
465
|
+
if (key.meta && !key.shift && (key.upArrow || key.downArrow))
|
|
466
|
+
return moveLines(key.upArrow ? -1 : 1);
|
|
141
467
|
if (key.upArrow)
|
|
142
|
-
return move((b) => ({ row: b.row - 1 }));
|
|
468
|
+
return move((b) => ({ row: b.row - 1, col: bufferCol(b.lines[b.row - 1] ?? "", screenCol(b.lines[b.row], b.col)) }), select);
|
|
143
469
|
if (key.downArrow)
|
|
144
|
-
return move((b) => ({ row: b.row + 1 }));
|
|
470
|
+
return move((b) => ({ row: b.row + 1, col: bufferCol(b.lines[b.row + 1] ?? "", screenCol(b.lines[b.row], b.col)) }), select);
|
|
145
471
|
if (key.leftArrow) {
|
|
146
|
-
|
|
472
|
+
const sel = selection(buf);
|
|
473
|
+
if (sel && !select && !word)
|
|
474
|
+
return move(() => sel.start);
|
|
475
|
+
return move((b) => (word ? { col: wordLeft(b.lines[b.row], b.col) }
|
|
476
|
+
: b.col > 0 ? { col: b.col - 1 } : b.row > 0 ? { row: b.row - 1, col: b.lines[b.row - 1].length } : {}), select);
|
|
147
477
|
}
|
|
148
478
|
if (key.rightArrow) {
|
|
149
|
-
|
|
479
|
+
const sel = selection(buf);
|
|
480
|
+
if (sel && !select && !word)
|
|
481
|
+
return move(() => sel.end);
|
|
482
|
+
return move((b) => (word ? { col: wordRight(b.lines[b.row], b.col) }
|
|
483
|
+
: b.col < b.lines[b.row].length ? { col: b.col + 1 } : b.row < b.lines.length - 1 ? { row: b.row + 1, col: 0 } : {}), select);
|
|
484
|
+
}
|
|
485
|
+
if (key.home) {
|
|
486
|
+
if (key.ctrl)
|
|
487
|
+
return move(() => ({ row: 0, col: 0 }), select);
|
|
488
|
+
return move((b) => {
|
|
489
|
+
const indent = b.lines[b.row].match(/^\s*/)[0].length;
|
|
490
|
+
return { col: b.col === indent ? 0 : indent };
|
|
491
|
+
}, select);
|
|
492
|
+
}
|
|
493
|
+
if (key.end) {
|
|
494
|
+
if (key.ctrl)
|
|
495
|
+
return move((b) => ({ row: b.lines.length - 1, col: b.lines[b.lines.length - 1].length }), select);
|
|
496
|
+
return move((b) => ({ col: b.lines[b.row].length }), select);
|
|
150
497
|
}
|
|
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
498
|
if (key.pageUp)
|
|
156
|
-
return move((b) => ({ row: b.row - rows }));
|
|
499
|
+
return move((b) => ({ row: b.row - rows }), select);
|
|
157
500
|
if (key.pageDown)
|
|
158
|
-
return move((b) => ({ row: b.row + rows }));
|
|
501
|
+
return move((b) => ({ row: b.row + rows }), select);
|
|
159
502
|
if (key.return) {
|
|
160
|
-
|
|
161
|
-
|
|
503
|
+
// Keep the indent; one level more after an opening bracket (or a colon, in Python).
|
|
504
|
+
const head = buf.lines[buf.row].slice(0, buf.col);
|
|
505
|
+
const indent = head.match(/^\s*/)[0];
|
|
506
|
+
const opens = /[{[(]\s*$/.test(head) || (/\.pyi?$/.test(filePath) && /:\s*$/.test(head));
|
|
507
|
+
const closes = /^\s*[}\])]/.test(buf.lines[buf.row].slice(buf.col));
|
|
508
|
+
if (opens && closes) {
|
|
509
|
+
// Between brackets: open an indented line and put the closing bracket below it.
|
|
510
|
+
insert(`\n${indent}${indentUnit}\n${indent}`);
|
|
511
|
+
return move((b) => ({ row: b.row - 1, col: indent.length + indentUnit.length }));
|
|
512
|
+
}
|
|
513
|
+
return insert(`\n${indent}${opens ? indentUnit : ""}`);
|
|
162
514
|
}
|
|
163
|
-
if (key.tab)
|
|
515
|
+
if (key.tab) {
|
|
516
|
+
// With a selection, or Shift+Tab: indent or outdent whole lines.
|
|
517
|
+
if (key.shift)
|
|
518
|
+
return eachLine((line) => line.replace(new RegExp(`^(\\t| {1,${indentUnit === "\t" ? 2 : indentUnit.length}})`), ""));
|
|
519
|
+
if (hasSelection)
|
|
520
|
+
return eachLine((line) => (line ? indentUnit + line : line));
|
|
164
521
|
return insert(indentUnit);
|
|
522
|
+
}
|
|
165
523
|
if (key.backspace) {
|
|
166
524
|
return edit((b) => {
|
|
525
|
+
if (selection(b))
|
|
526
|
+
return deleteSelection(b);
|
|
167
527
|
const lines = [...b.lines];
|
|
528
|
+
const line = lines[b.row];
|
|
168
529
|
if (b.col > 0) {
|
|
169
|
-
const
|
|
170
|
-
lines[b.row] = line.slice(0,
|
|
171
|
-
return { lines, row: b.row, col:
|
|
530
|
+
const to = word ? wordLeft(line, b.col) : b.col - 1;
|
|
531
|
+
lines[b.row] = line.slice(0, to) + line.slice(b.col);
|
|
532
|
+
return { lines, row: b.row, col: to, anchor: null };
|
|
172
533
|
}
|
|
173
534
|
if (b.row === 0)
|
|
174
535
|
return b;
|
|
175
536
|
const prev = lines[b.row - 1];
|
|
176
|
-
lines.splice(b.row - 1, 2, prev +
|
|
177
|
-
return { lines, row: b.row - 1, col: prev.length };
|
|
537
|
+
lines.splice(b.row - 1, 2, prev + line);
|
|
538
|
+
return { lines, row: b.row - 1, col: prev.length, anchor: null };
|
|
178
539
|
}, "delete");
|
|
179
540
|
}
|
|
180
541
|
if (key.delete) {
|
|
181
542
|
return edit((b) => {
|
|
543
|
+
if (selection(b))
|
|
544
|
+
return deleteSelection(b);
|
|
182
545
|
const lines = [...b.lines];
|
|
183
546
|
const line = lines[b.row];
|
|
184
547
|
if (b.col < line.length)
|
|
185
|
-
lines[b.row] = line.slice(0, b.col) + line.slice(b.col + 1);
|
|
548
|
+
lines[b.row] = line.slice(0, b.col) + line.slice(word ? wordRight(line, b.col) : b.col + 1);
|
|
186
549
|
else if (b.row < lines.length - 1)
|
|
187
550
|
lines.splice(b.row, 2, line + lines[b.row + 1]);
|
|
188
|
-
return { ...b, lines };
|
|
551
|
+
return { ...b, lines, anchor: null };
|
|
189
552
|
});
|
|
190
553
|
}
|
|
191
554
|
// Letters/digits group into one undo step per word; spaces and punctuation start a new one.
|
|
192
555
|
if (input && !key.ctrl && !key.meta)
|
|
193
556
|
insert(input, /^\w+$/.test(input) ? "type" : "other");
|
|
194
557
|
}, { 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);
|
|
558
|
+
// Keep the cursor on screen after it moves.
|
|
198
559
|
const v = view.current;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
560
|
+
const cursorX = screenCol(buf.lines[buf.row], buf.col);
|
|
561
|
+
if (reveal.current) {
|
|
562
|
+
if (buf.row < v.top)
|
|
563
|
+
v.top = buf.row;
|
|
564
|
+
if (buf.row >= v.top + rows)
|
|
565
|
+
v.top = buf.row - rows + 1;
|
|
566
|
+
if (cursorX < v.left)
|
|
567
|
+
v.left = Math.max(0, cursorX - 8);
|
|
568
|
+
if (cursorX >= v.left + textWidth)
|
|
569
|
+
v.left = cursorX - textWidth + 8;
|
|
570
|
+
}
|
|
571
|
+
v.top = Math.max(0, Math.min(v.top, maxTop));
|
|
208
572
|
const visible = buf.lines.slice(v.top, v.top + rows);
|
|
209
573
|
const colored = useMemo(
|
|
210
|
-
// Scrolled sideways: every line shifts by the same amount
|
|
211
|
-
//
|
|
574
|
+
// Scrolled sideways: every line shifts by the same amount (plain text, since slicing highlighted
|
|
575
|
+
// text could cut a color code in half).
|
|
212
576
|
() => (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
577
|
const { icon, color } = fileIcon(filePath);
|
|
214
|
-
const byLine =
|
|
215
|
-
|
|
216
|
-
|
|
578
|
+
const byLine = useMemo(() => {
|
|
579
|
+
const map = new Map();
|
|
580
|
+
for (const d of problems)
|
|
581
|
+
map.set(d.line, [...(map.get(d.line) ?? []), d]);
|
|
582
|
+
return map;
|
|
583
|
+
}, [problems]);
|
|
584
|
+
const sel = selection(buf);
|
|
217
585
|
const cursorProblem = byLine.get(buf.row + 1)?.[0];
|
|
586
|
+
const selectedCount = sel ? selectedText(buf).length : 0;
|
|
218
587
|
const status = confirmExit
|
|
219
588
|
? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
|
|
220
|
-
:
|
|
221
|
-
?
|
|
222
|
-
:
|
|
223
|
-
|
|
589
|
+
: prompt
|
|
590
|
+
? prompt.kind === "find" ? `Find: ${prompt.text}▏ Enter/↓ next · Shift+Enter/↑ previous · Esc close` : `Go to line: ${prompt.text}▏ Enter go · Esc cancel`
|
|
591
|
+
: flash ?? (cursorProblem
|
|
592
|
+
? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
|
|
593
|
+
: `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`);
|
|
594
|
+
const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
|
|
224
595
|
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
|
-
|
|
596
|
+
const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
|
|
597
|
+
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) => {
|
|
598
|
+
const n = v.top + i;
|
|
599
|
+
const isCursorLine = n === buf.row;
|
|
600
|
+
const problem = byLine.get(n + 1)?.[0];
|
|
601
|
+
const mark = problem ? SEVERITY_STYLE[problem.severity] : null;
|
|
602
|
+
const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
|
|
603
|
+
const inSelection = sel && n >= sel.start.row && n <= sel.end.row;
|
|
604
|
+
if (!isCursorLine && !inSelection) {
|
|
605
|
+
return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
|
|
606
|
+
}
|
|
607
|
+
// Lines with the cursor or a selection show plain text, so both can be drawn exactly.
|
|
608
|
+
const shown = expand(line).slice(v.left, v.left + textWidth);
|
|
609
|
+
const from = inSelection ? Math.max(0, (n === sel.start.row ? screenCol(line, sel.start.col) : 0) - v.left) : 0;
|
|
610
|
+
const to = inSelection ? Math.max(0, (n === sel.end.row ? screenCol(line, sel.end.col) : expand(line).length + 1) - v.left) : 0;
|
|
611
|
+
const at = isCursorLine ? cursorX - v.left : -1;
|
|
612
|
+
const cells = [];
|
|
613
|
+
for (let x = 0; x < Math.max(shown.length, at + 1, Math.min(to, textWidth)); x++) {
|
|
614
|
+
const ch = shown[x] ?? " ";
|
|
615
|
+
const cell = { text: ch, selected: x >= from && x < to, cursor: x === at && focused };
|
|
616
|
+
const last = cells[cells.length - 1];
|
|
617
|
+
if (last && !cell.cursor && !last.cursor && last.selected === cell.selected)
|
|
618
|
+
last.text += ch;
|
|
619
|
+
else
|
|
620
|
+
cells.push(cell);
|
|
621
|
+
}
|
|
622
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [num, cells.map((c, j) => c.cursor
|
|
623
|
+
? _jsx(Text, { inverse: true, color: C.green, children: c.text }, j)
|
|
624
|
+
: _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j))] }, n));
|
|
625
|
+
}) }), _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}` })] }));
|
|
626
|
+
});
|