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.
@@ -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
- const expand = (s) => s.replace(/\t/g, " ");
9
- /** A small modal text editor: arrows to move, type to insert, Ctrl+S save, Ctrl+Z undo, Esc leave. */
10
- export function Editor({ width, height, focused, filePath, initial, baseline, startLine, problems, autoSave, syntaxVersion, wheel, onSave, onExit, onState, onHover, onDefinition }) {
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
- return { lines, row, col: lines[row].match(/^\s*/)[0].length };
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
- useEffect(() => onState({ line: buf.row + 1, col: buf.col + 1, dirty, lines: buf.lines }), [buf, dirty]);
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") => 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 };
45
- return fn(b);
46
- });
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) => {
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 before = line.slice(0, b.col);
59
- const after = line.slice(b.col);
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] = before + text + after;
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, before + pieces[0], ...pieces.slice(1, -1), last + after);
67
- return { lines, row: b.row + pieces.length - 1, col: last.length };
68
- }, kind);
69
- const move = (fn) => setBuf((b) => {
70
- const next = { ...b, ...fn(b) };
71
- next.row = Math.max(0, Math.min(next.lines.length - 1, next.row));
72
- next.col = Math.max(0, Math.min(next.lines[next.row].length, next.col));
73
- return next;
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
- move((b) => ({ row: b.row + wheel.dir * 3 }));
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
- usePaste((text) => insert(text), { isActive: focused });
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
- if (key.ctrl && input === "c")
114
- return; // the app handles Ctrl+C (press twice to quit)
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
- return move((b) => (b.col > 0 ? { col: b.col - 1 } : b.row > 0 ? { row: b.row - 1, col: b.lines[b.row - 1].length } : {}));
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
- return move((b) => (b.col < b.lines[b.row].length ? { col: b.col + 1 } : b.row < b.lines.length - 1 ? { row: b.row + 1, col: 0 } : {}));
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
- const indent = buf.lines[buf.row].match(/^\s*/)[0];
161
- return insert(`\n${indent}`);
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 line = lines[b.row];
170
- lines[b.row] = line.slice(0, b.col - 1) + line.slice(b.col);
171
- return { lines, row: b.row, col: b.col - 1 };
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 + lines[b.row]);
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
- if (buf.row < v.top)
200
- v.top = buf.row;
201
- if (buf.row >= v.top + rows)
202
- v.top = buf.row - rows + 1;
203
- const cursorX = expand(buf.lines[buf.row].slice(0, buf.col)).length;
204
- if (cursorX < v.left)
205
- v.left = Math.max(0, cursorX - 8);
206
- if (cursorX >= v.left + textWidth)
207
- v.left = cursorX - textWidth + 8;
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 as the cursor line (plain text, since
211
- // slicing highlighted text could cut a color code in half).
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 = new Map();
215
- for (const d of problems)
216
- byLine.set(d.line, [...(byLine.get(d.line) ?? []), d]);
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
- : flash ?? (cursorProblem
221
- ? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
222
- : `Ln ${buf.row + 1}, Col ${buf.col + 1}${dirty ? " ● modified" : ""}`);
223
- const statusColor = confirmExit ? C.red : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
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
- 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) => {
226
- const n = v.top + i;
227
- const isCursorLine = n === buf.row;
228
- const problem = byLine.get(n + 1)?.[0];
229
- const mark = problem ? SEVERITY_STYLE[problem.severity] : null;
230
- const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
231
- if (!isCursorLine) {
232
- return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
233
- }
234
- const shown = expand(line).slice(v.left, v.left + textWidth);
235
- const at = cursorX - v.left;
236
- 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));
237
- }) }), 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}` })] }));
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
+ });