nou-cli 0.3.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.
@@ -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 };
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
- 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; 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
- if (key.ctrl && input === "c")
114
- return; // the app handles Ctrl+C (press twice to quit)
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
- return move((b) => (b.col > 0 ? { col: b.col - 1 } : b.row > 0 ? { row: b.row - 1, col: b.lines[b.row - 1].length } : {}));
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
- 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 } : {}));
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
- const indent = buf.lines[buf.row].match(/^\s*/)[0];
161
- return insert(`\n${indent}`);
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 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 };
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 + lines[b.row]);
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
- 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;
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 as the cursor line (plain text, since
211
- // slicing highlighted text could cut a color code in half).
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 = new Map();
215
- for (const d of problems)
216
- byLine.set(d.line, [...(byLine.get(d.line) ?? []), d]);
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
- : 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;
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
- 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)) })), _jsxs(Text, { wrap: "truncate-end", color: statusColor, children: [` ${status}`, _jsx(Text, { color: C.dim, children: confirmExit ? "" : ` Ctrl+S save${autoSave === "off" ? "" : " (auto)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close` })] })] }));
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
+ });