@beast01/tcurl 1.0.0 → 1.0.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 +36 -14
- package/dist/app.js +18 -1
- package/dist/components/Help.js +28 -12
- package/dist/components/RequestEditor.js +26 -7
- package/dist/components/ResponseViewer.js +9 -4
- package/dist/components/TextEditor.js +251 -78
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,13 +39,14 @@ tcurl --theme ember # start with the Ember theme
|
|
|
39
39
|
tcurl --help
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
### Keyboard
|
|
42
|
+
### Keyboard — vim/nvim motions everywhere
|
|
43
43
|
|
|
44
44
|
**List view**
|
|
45
45
|
|
|
46
46
|
| Key | Action |
|
|
47
47
|
| ------------ | ------------------------------- |
|
|
48
|
-
|
|
|
48
|
+
| `j` / `k` | Move selection down / up |
|
|
49
|
+
| `gg` / `G` | First / last request |
|
|
49
50
|
| `n` | New request |
|
|
50
51
|
| `Enter` / `e`| Edit request |
|
|
51
52
|
| `s` | Send request |
|
|
@@ -55,25 +56,43 @@ tcurl --help
|
|
|
55
56
|
| `?` | Help |
|
|
56
57
|
| `q` / `Ctrl+C` | Quit |
|
|
57
58
|
|
|
58
|
-
**Editor**
|
|
59
|
+
**Editor — field navigation**
|
|
59
60
|
|
|
60
61
|
| Key | Action |
|
|
61
62
|
| ------------------ | -------------------------------------------- |
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
63
|
+
| `j` / `k` / `Tab` | Move between fields |
|
|
64
|
+
| `gg` / `G` | First / last field |
|
|
65
|
+
| `h` / `l` | Change method, body type, toggles |
|
|
66
|
+
| `i` / `a` / `Enter`| Edit the focused field |
|
|
66
67
|
| `Esc` | Back to list (auto-saves) |
|
|
67
68
|
|
|
69
|
+
**Field editor — modal (INSERT / NORMAL, like nvim)**
|
|
70
|
+
|
|
71
|
+
Fields open in **INSERT** mode so you can type right away; press `Esc` for
|
|
72
|
+
**NORMAL** mode and vim motions.
|
|
73
|
+
|
|
74
|
+
| Key | Action |
|
|
75
|
+
| ------------------- | ---------------------------------------- |
|
|
76
|
+
| `Esc` | INSERT → NORMAL |
|
|
77
|
+
| `i` `a` `I` `A` `o` `O` | NORMAL → INSERT (at cursor, after, line start/end, open line) |
|
|
78
|
+
| `h` `j` `k` `l` | Move by char / line |
|
|
79
|
+
| `w` / `b` / `e` | Word forward / back / end |
|
|
80
|
+
| `0` / `$` | Line start / end |
|
|
81
|
+
| `gg` / `G` | Buffer start / end |
|
|
82
|
+
| `x` / `dd` | Delete char / line |
|
|
83
|
+
| `Enter` / `Ctrl+S` | Save the field |
|
|
84
|
+
| `Esc` (in NORMAL) | Cancel the edit |
|
|
85
|
+
|
|
68
86
|
**Response**
|
|
69
87
|
|
|
70
|
-
| Key
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
88
|
+
| Key | Action |
|
|
89
|
+
| ----------------- | --------------------- |
|
|
90
|
+
| `j` / `k` | Scroll down / up |
|
|
91
|
+
| `Ctrl+d` / `Ctrl+u` | Half-page down / up |
|
|
92
|
+
| `Ctrl+f` / `Ctrl+b` | Full-page down / up |
|
|
93
|
+
| `g` / `G` | Top / bottom |
|
|
94
|
+
| `h` / `l` / `Tab` | Body ↔ Headers |
|
|
95
|
+
| `q` / `Esc` | Back |
|
|
77
96
|
|
|
78
97
|
## Features
|
|
79
98
|
|
|
@@ -90,6 +109,9 @@ tcurl --help
|
|
|
90
109
|
with status, timing, and size.
|
|
91
110
|
- **Persistence** — requests are stored as JSON and reused across sessions.
|
|
92
111
|
- **Export** — copy any saved request as an equivalent `curl` command.
|
|
112
|
+
- **Vim/nvim motions** — `j`/`k`, `gg`/`G`, `h`/`l` navigation throughout, plus
|
|
113
|
+
a fully modal (INSERT / NORMAL) field editor with `w`/`b`/`e`, `0`/`$`,
|
|
114
|
+
`x`, `dd`, `o`/`O`, and `Ctrl+d`/`Ctrl+u` scrolling.
|
|
93
115
|
|
|
94
116
|
## Where is my data?
|
|
95
117
|
|
package/dist/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
3
3
|
import { getTheme, themes } from './theme.js';
|
|
4
4
|
import { defaultRequest, saveStore } from './storage.js';
|
|
@@ -38,6 +38,7 @@ export function App({ initialStore, exitState, }) {
|
|
|
38
38
|
const [response, setResponse] = useState(null);
|
|
39
39
|
const [loading, setLoading] = useState(false);
|
|
40
40
|
const [flash, setFlash] = useState(null);
|
|
41
|
+
const pendingG = useRef(false);
|
|
41
42
|
const theme = getTheme(store.theme);
|
|
42
43
|
const requests = store.requests;
|
|
43
44
|
function persist(next) {
|
|
@@ -89,6 +90,22 @@ export function App({ initialStore, exitState, }) {
|
|
|
89
90
|
persist({ ...store, theme: next });
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
93
|
+
// gg → first, G → last (vim).
|
|
94
|
+
if (pendingG.current) {
|
|
95
|
+
pendingG.current = false;
|
|
96
|
+
if (input === 'g') {
|
|
97
|
+
setSelected(0);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (input === 'g') {
|
|
102
|
+
pendingG.current = true;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (input === 'G') {
|
|
106
|
+
setSelected(Math.max(0, requests.length - 1));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
92
109
|
if (key.upArrow || input === 'k') {
|
|
93
110
|
setSelected((s) => Math.max(0, s - 1));
|
|
94
111
|
return;
|
package/dist/components/Help.js
CHANGED
|
@@ -2,9 +2,10 @@ import React from 'react';
|
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
const SECTIONS = [
|
|
4
4
|
{
|
|
5
|
-
title: 'List view',
|
|
5
|
+
title: 'List view (vim keys)',
|
|
6
6
|
rows: [
|
|
7
|
-
['
|
|
7
|
+
['j / k', 'Move selection down / up'],
|
|
8
|
+
['gg / G', 'First / last request'],
|
|
8
9
|
['n', 'New request'],
|
|
9
10
|
['Enter / e', 'Edit request'],
|
|
10
11
|
['s', 'Send request'],
|
|
@@ -16,23 +17,38 @@ const SECTIONS = [
|
|
|
16
17
|
],
|
|
17
18
|
},
|
|
18
19
|
{
|
|
19
|
-
title: 'Editor',
|
|
20
|
+
title: 'Editor (field navigation)',
|
|
20
21
|
rows: [
|
|
21
|
-
['
|
|
22
|
-
['
|
|
23
|
-
['
|
|
24
|
-
['
|
|
22
|
+
['j / k / Tab', 'Move between fields'],
|
|
23
|
+
['gg / G', 'First / last field'],
|
|
24
|
+
['h / l', 'Change method, body type, toggles'],
|
|
25
|
+
['i / a / Enter', 'Edit the focused field'],
|
|
25
26
|
['Esc', 'Back to list (auto-saves)'],
|
|
26
27
|
],
|
|
27
28
|
},
|
|
28
29
|
{
|
|
29
|
-
title: '
|
|
30
|
+
title: 'Field editor — modal (INSERT / NORMAL)',
|
|
30
31
|
rows: [
|
|
31
|
-
['
|
|
32
|
-
['
|
|
32
|
+
['Esc', 'INSERT → NORMAL'],
|
|
33
|
+
['i a I A o O', 'NORMAL → INSERT (various)'],
|
|
34
|
+
['h j k l', 'Move by char / line'],
|
|
35
|
+
['w / b / e', 'Word forward / back / end'],
|
|
36
|
+
['0 / $', 'Line start / end'],
|
|
37
|
+
['gg / G', 'Buffer start / end'],
|
|
38
|
+
['x / dd', 'Delete char / line'],
|
|
39
|
+
['Enter / Ctrl+S', 'Save field'],
|
|
40
|
+
['Esc (in NORMAL)', 'Cancel edit'],
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
title: 'Response (vim keys)',
|
|
45
|
+
rows: [
|
|
46
|
+
['j / k', 'Scroll down / up'],
|
|
47
|
+
['Ctrl+d / Ctrl+u', 'Half-page down / up'],
|
|
48
|
+
['Ctrl+f / Ctrl+b', 'Full-page down / up'],
|
|
33
49
|
['g / G', 'Top / bottom'],
|
|
34
|
-
['
|
|
35
|
-
['Esc', 'Back'],
|
|
50
|
+
['h / l / Tab', 'Switch body / headers'],
|
|
51
|
+
['q / Esc', 'Back'],
|
|
36
52
|
],
|
|
37
53
|
},
|
|
38
54
|
];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import { HTTP_METHODS } from '../types.js';
|
|
4
4
|
import { methodColor } from '../theme.js';
|
|
@@ -9,6 +9,7 @@ const AUTH_TYPES = ['none', 'bearer', 'basic'];
|
|
|
9
9
|
export function RequestEditor({ theme, request, isActive, onChange, onSend, onBack, }) {
|
|
10
10
|
const [focus, setFocus] = useState(0);
|
|
11
11
|
const [editingKey, setEditingKey] = useState(null);
|
|
12
|
+
const pending = useRef(false);
|
|
12
13
|
const fields = buildFields(request, theme);
|
|
13
14
|
const editing = editingKey !== null;
|
|
14
15
|
useInput((input, key) => {
|
|
@@ -16,23 +17,41 @@ export function RequestEditor({ theme, request, isActive, onChange, onSend, onBa
|
|
|
16
17
|
onBack();
|
|
17
18
|
return;
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
+
// gg → first field, G → last field (vim).
|
|
21
|
+
if (pending.current) {
|
|
22
|
+
pending.current = false;
|
|
23
|
+
if (input === 'g') {
|
|
24
|
+
setFocus(0);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (input === 'g') {
|
|
29
|
+
pending.current = true;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (input === 'G') {
|
|
33
|
+
setFocus(fields.length - 1);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (key.upArrow || input === 'k' || (key.shift && key.tab)) {
|
|
20
37
|
setFocus((f) => (f - 1 + fields.length) % fields.length);
|
|
21
38
|
return;
|
|
22
39
|
}
|
|
23
|
-
if (key.downArrow || key.tab) {
|
|
40
|
+
if (key.downArrow || input === 'j' || key.tab) {
|
|
24
41
|
setFocus((f) => (f + 1) % fields.length);
|
|
25
42
|
return;
|
|
26
43
|
}
|
|
27
44
|
const field = fields[focus];
|
|
28
45
|
if (!field)
|
|
29
46
|
return;
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
// h/l (and arrows) change cycles/toggles.
|
|
48
|
+
if (key.leftArrow || key.rightArrow || input === 'h' || input === 'l') {
|
|
49
|
+
const dir = key.leftArrow || input === 'h' ? -1 : 1;
|
|
32
50
|
applyCycle(field.key, dir);
|
|
33
51
|
return;
|
|
34
52
|
}
|
|
35
|
-
|
|
53
|
+
// Enter / i / a / Space edit or activate the focused field.
|
|
54
|
+
if (key.return || input === ' ' || input === 'i' || input === 'a') {
|
|
36
55
|
activate(field);
|
|
37
56
|
return;
|
|
38
57
|
}
|
|
@@ -136,7 +155,7 @@ export function RequestEditor({ theme, request, isActive, onChange, onSend, onBa
|
|
|
136
155
|
React.createElement(Text, { color: f.color ?? (focused ? theme.fg : theme.fgDim), wrap: "truncate-end" }, f.display)));
|
|
137
156
|
}),
|
|
138
157
|
React.createElement(Box, { height: 1 }),
|
|
139
|
-
React.createElement(Text, { color: theme.muted }, "
|
|
158
|
+
React.createElement(Text, { color: theme.muted }, "j/k move \u00B7 gg/G ends \u00B7 h/l change \u00B7 i/Enter edit \u00B7 Esc back")));
|
|
140
159
|
}
|
|
141
160
|
function buildFields(request, theme) {
|
|
142
161
|
const fields = [
|
|
@@ -21,7 +21,7 @@ export function ResponseViewer({ theme, response, isActive, onBack, width, heigh
|
|
|
21
21
|
onBack();
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
|
-
if (input === 'h' || input === '
|
|
24
|
+
if ((!key.ctrl && (input === 'h' || input === 'l')) || key.tab) {
|
|
25
25
|
setTab((t) => (t === 'body' ? 'headers' : 'body'));
|
|
26
26
|
setScroll(0);
|
|
27
27
|
return;
|
|
@@ -30,10 +30,15 @@ export function ResponseViewer({ theme, response, isActive, onBack, width, heigh
|
|
|
30
30
|
setScroll((s) => Math.max(0, s - 1));
|
|
31
31
|
if (key.downArrow || input === 'j')
|
|
32
32
|
setScroll((s) => Math.min(maxScroll, s + 1));
|
|
33
|
-
if (key.pageUp)
|
|
33
|
+
if (key.pageUp || (key.ctrl && input === 'b'))
|
|
34
34
|
setScroll((s) => Math.max(0, s - viewport));
|
|
35
|
-
if (key.pageDown)
|
|
35
|
+
if (key.pageDown || (key.ctrl && input === 'f'))
|
|
36
36
|
setScroll((s) => Math.min(maxScroll, s + viewport));
|
|
37
|
+
// Ctrl+U / Ctrl+D — half-page scroll (vim).
|
|
38
|
+
if (key.ctrl && input === 'u')
|
|
39
|
+
setScroll((s) => Math.max(0, s - Math.floor(viewport / 2)));
|
|
40
|
+
if (key.ctrl && input === 'd')
|
|
41
|
+
setScroll((s) => Math.min(maxScroll, s + Math.floor(viewport / 2)));
|
|
37
42
|
if (input === 'g')
|
|
38
43
|
setScroll(0);
|
|
39
44
|
if (input === 'G')
|
|
@@ -66,7 +71,7 @@ export function ResponseViewer({ theme, response, isActive, onBack, width, heigh
|
|
|
66
71
|
React.createElement(Text, { color: theme.muted }, lines.length > viewport
|
|
67
72
|
? `line ${clampedScroll + 1}-${Math.min(clampedScroll + viewport, lines.length)} / ${lines.length}`
|
|
68
73
|
: `${lines.length} lines`),
|
|
69
|
-
React.createElement(Text, { color: theme.muted }, "
|
|
74
|
+
React.createElement(Text, { color: theme.muted }, "j/k scroll \u00B7 Ctrl+d/u half-page \u00B7 h/l tabs \u00B7 g/G ends \u00B7 q back"))));
|
|
70
75
|
}
|
|
71
76
|
function Tabs({ theme, tab, headerCount, }) {
|
|
72
77
|
const item = (id, label) => (React.createElement(Text, { backgroundColor: tab === id ? theme.selectionBg : undefined, color: tab === id ? theme.accent : theme.fgDim, bold: tab === id },
|
|
@@ -1,90 +1,263 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useEffect, useReducer } from 'react';
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
const WORD = /\w/;
|
|
3
4
|
/**
|
|
4
|
-
* A
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* A modal (vim/nvim-style) text editor supporting single- and multi-line input.
|
|
6
|
+
*
|
|
7
|
+
* INSERT: type text; Enter inserts a newline (multiline) or submits; Esc → NORMAL.
|
|
8
|
+
* NORMAL: h/j/k/l move, w/b/e word motions, 0/$ line ends, gg/G buffer ends,
|
|
9
|
+
* x deletes a char, dd deletes a line, i/a/I/A/o/O enter INSERT, Enter or
|
|
10
|
+
* Ctrl+S saves, Esc cancels.
|
|
11
|
+
*
|
|
12
|
+
* State lives in a reducer so every keystroke is computed atomically from the
|
|
13
|
+
* previous state — this keeps combos (gg, dd) and fast typing correct.
|
|
7
14
|
*/
|
|
8
15
|
export function TextEditor({ initialValue, multiline = false, masked = false, theme, label, onSubmit, onCancel, }) {
|
|
9
|
-
const [
|
|
10
|
-
|
|
16
|
+
const [state, dispatch] = useReducer(reducer, {
|
|
17
|
+
value: initialValue,
|
|
18
|
+
cursor: initialValue.length,
|
|
19
|
+
mode: 'insert',
|
|
20
|
+
pending: '',
|
|
21
|
+
multiline,
|
|
22
|
+
exit: '',
|
|
23
|
+
});
|
|
11
24
|
useInput((input, key) => {
|
|
12
|
-
if (
|
|
13
|
-
onCancel();
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
// Ctrl+S always submits.
|
|
17
|
-
if (key.ctrl && input === 's') {
|
|
18
|
-
onSubmit(value);
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
if (key.return) {
|
|
22
|
-
if (multiline) {
|
|
23
|
-
insert('\n');
|
|
24
|
-
}
|
|
25
|
-
else {
|
|
26
|
-
onSubmit(value);
|
|
27
|
-
}
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
if (key.leftArrow) {
|
|
31
|
-
setCursor((c) => Math.max(0, c - 1));
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
if (key.rightArrow) {
|
|
35
|
-
setCursor((c) => Math.min(value.length, c + 1));
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
if (key.upArrow || key.downArrow) {
|
|
39
|
-
moveVertical(key.upArrow ? -1 : 1);
|
|
25
|
+
if (state.exit)
|
|
40
26
|
return;
|
|
41
|
-
}
|
|
42
|
-
if (key.backspace || key.delete) {
|
|
43
|
-
if (cursor > 0) {
|
|
44
|
-
setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
|
|
45
|
-
setCursor((c) => Math.max(0, c - 1));
|
|
46
|
-
}
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
// Ignore other control keys (tab, etc.).
|
|
50
|
-
if (key.tab || key.ctrl || key.meta)
|
|
51
|
-
return;
|
|
52
|
-
if (input)
|
|
53
|
-
insert(input);
|
|
27
|
+
dispatch({ input, key });
|
|
54
28
|
});
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const curLineIdx = lines.length - 1;
|
|
65
|
-
const targetIdx = curLineIdx + dir;
|
|
66
|
-
if (targetIdx < 0 || targetIdx >= allLines.length)
|
|
67
|
-
return;
|
|
68
|
-
let offset = 0;
|
|
69
|
-
for (let i = 0; i < targetIdx; i++)
|
|
70
|
-
offset += allLines[i].length + 1;
|
|
71
|
-
offset += Math.min(col, allLines[targetIdx].length);
|
|
72
|
-
setCursor(offset);
|
|
73
|
-
}
|
|
74
|
-
const display = masked ? '•'.repeat(value.length) : value;
|
|
75
|
-
// Insert a visible cursor block.
|
|
76
|
-
const withCursor = display.slice(0, cursor) +
|
|
77
|
-
(cursor < display.length ? `█` : '█') +
|
|
78
|
-
display.slice(cursor + (cursor < display.length ? 1 : 0));
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (state.exit === 'submit')
|
|
31
|
+
onSubmit(state.value);
|
|
32
|
+
else if (state.exit === 'cancel')
|
|
33
|
+
onCancel();
|
|
34
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
+
}, [state.exit]);
|
|
36
|
+
const display = masked ? '•'.repeat(state.value.length) : state.value;
|
|
37
|
+
const lines = renderWithCursor(display, state.cursor, state.mode);
|
|
79
38
|
return (React.createElement(Box, { flexDirection: "column" },
|
|
80
|
-
React.createElement(Box,
|
|
39
|
+
React.createElement(Box, { justifyContent: "space-between" },
|
|
81
40
|
React.createElement(Text, { color: theme.accent, bold: true },
|
|
82
41
|
'✎ ',
|
|
83
|
-
label)
|
|
84
|
-
|
|
85
|
-
|
|
42
|
+
label),
|
|
43
|
+
React.createElement(Text, { backgroundColor: state.mode === 'insert' ? theme.success : theme.accentAlt, color: theme.bg, bold: true }, state.mode === 'insert' ? ' INSERT ' : ' NORMAL ')),
|
|
44
|
+
React.createElement(Box, { borderStyle: "round", borderColor: state.mode === 'insert' ? theme.success : theme.borderActive, paddingX: 1, flexDirection: "column", minHeight: multiline ? 6 : 1 }, lines.map((line, i) => (React.createElement(Text, { key: i, color: theme.fg }, line.length ? line : ' ')))),
|
|
86
45
|
React.createElement(Box, null,
|
|
87
|
-
React.createElement(Text, { color: theme.muted },
|
|
88
|
-
?
|
|
89
|
-
|
|
46
|
+
React.createElement(Text, { color: theme.muted }, state.mode === 'insert'
|
|
47
|
+
? multiline
|
|
48
|
+
? 'type · Enter: newline · Esc: normal · Ctrl+S: save'
|
|
49
|
+
: 'type · Enter: save · Esc: normal · Ctrl+S: save'
|
|
50
|
+
: 'h j k l · w b e · 0 $ · gg G · x dd · i a o · Enter: save · Esc: cancel'))));
|
|
51
|
+
}
|
|
52
|
+
function reducer(s, a) {
|
|
53
|
+
const { input, key } = a;
|
|
54
|
+
// Ctrl+S submits from any mode.
|
|
55
|
+
if (key.ctrl && input === 's')
|
|
56
|
+
return { ...s, exit: 'submit' };
|
|
57
|
+
return s.mode === 'insert' ? insertMode(s, input, key) : normalMode(s, input, key);
|
|
58
|
+
}
|
|
59
|
+
function insertMode(s, input, key) {
|
|
60
|
+
if (key.escape)
|
|
61
|
+
return { ...s, mode: 'normal' };
|
|
62
|
+
if (key.return) {
|
|
63
|
+
if (s.multiline)
|
|
64
|
+
return insertText(s, '\n');
|
|
65
|
+
return { ...s, exit: 'submit' };
|
|
66
|
+
}
|
|
67
|
+
if (key.leftArrow)
|
|
68
|
+
return { ...s, cursor: Math.max(0, s.cursor - 1) };
|
|
69
|
+
if (key.rightArrow)
|
|
70
|
+
return { ...s, cursor: Math.min(s.value.length, s.cursor + 1) };
|
|
71
|
+
if (key.upArrow)
|
|
72
|
+
return moveVertical(s, -1);
|
|
73
|
+
if (key.downArrow)
|
|
74
|
+
return moveVertical(s, 1);
|
|
75
|
+
if (key.backspace || key.delete) {
|
|
76
|
+
if (s.cursor > 0) {
|
|
77
|
+
return {
|
|
78
|
+
...s,
|
|
79
|
+
value: s.value.slice(0, s.cursor - 1) + s.value.slice(s.cursor),
|
|
80
|
+
cursor: s.cursor - 1,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
if (key.tab || key.ctrl || key.meta)
|
|
86
|
+
return s;
|
|
87
|
+
if (input)
|
|
88
|
+
return insertText(s, input);
|
|
89
|
+
return s;
|
|
90
|
+
}
|
|
91
|
+
function normalMode(s, input, key) {
|
|
92
|
+
// Resolve pending two-key operators.
|
|
93
|
+
if (s.pending === 'g') {
|
|
94
|
+
if (input === 'g')
|
|
95
|
+
return { ...s, pending: '', cursor: 0 };
|
|
96
|
+
s = { ...s, pending: '' }; // drop, then process as a fresh key below
|
|
97
|
+
}
|
|
98
|
+
if (s.pending === 'd') {
|
|
99
|
+
if (input === 'd')
|
|
100
|
+
return deleteLine({ ...s, pending: '' });
|
|
101
|
+
s = { ...s, pending: '' };
|
|
102
|
+
}
|
|
103
|
+
if (key.escape)
|
|
104
|
+
return { ...s, exit: 'cancel' };
|
|
105
|
+
if (key.return)
|
|
106
|
+
return { ...s, exit: 'submit' };
|
|
107
|
+
// Motions
|
|
108
|
+
if (input === 'h' || key.leftArrow)
|
|
109
|
+
return { ...s, cursor: Math.max(0, s.cursor - 1) };
|
|
110
|
+
if (input === 'l' || key.rightArrow)
|
|
111
|
+
return { ...s, cursor: Math.min(s.value.length, s.cursor + 1) };
|
|
112
|
+
if (input === 'j' || key.downArrow)
|
|
113
|
+
return moveVertical(s, 1);
|
|
114
|
+
if (input === 'k' || key.upArrow)
|
|
115
|
+
return moveVertical(s, -1);
|
|
116
|
+
if (input === '0')
|
|
117
|
+
return { ...s, cursor: lineStart(s.value, s.cursor) };
|
|
118
|
+
if (input === '$')
|
|
119
|
+
return { ...s, cursor: lineEnd(s.value, s.cursor) };
|
|
120
|
+
if (input === 'w')
|
|
121
|
+
return { ...s, cursor: wordForward(s.value, s.cursor) };
|
|
122
|
+
if (input === 'b')
|
|
123
|
+
return { ...s, cursor: wordBack(s.value, s.cursor) };
|
|
124
|
+
if (input === 'e')
|
|
125
|
+
return { ...s, cursor: wordEnd(s.value, s.cursor) };
|
|
126
|
+
if (input === 'G')
|
|
127
|
+
return { ...s, cursor: lastLineStart(s.value) };
|
|
128
|
+
if (input === 'g')
|
|
129
|
+
return { ...s, pending: 'g' };
|
|
130
|
+
if (input === 'd')
|
|
131
|
+
return { ...s, pending: 'd' };
|
|
132
|
+
// Edits
|
|
133
|
+
if (input === 'x')
|
|
134
|
+
return deleteChar(s);
|
|
135
|
+
if (input === 'i')
|
|
136
|
+
return { ...s, mode: 'insert' };
|
|
137
|
+
if (input === 'a')
|
|
138
|
+
return { ...s, mode: 'insert', cursor: Math.min(s.value.length, s.cursor + 1) };
|
|
139
|
+
if (input === 'I')
|
|
140
|
+
return { ...s, mode: 'insert', cursor: lineStart(s.value, s.cursor) };
|
|
141
|
+
if (input === 'A')
|
|
142
|
+
return { ...s, mode: 'insert', cursor: lineEnd(s.value, s.cursor) };
|
|
143
|
+
if (input === 'o' && s.multiline)
|
|
144
|
+
return openLine(s, 1);
|
|
145
|
+
if (input === 'O' && s.multiline)
|
|
146
|
+
return openLine(s, -1);
|
|
147
|
+
return s;
|
|
148
|
+
}
|
|
149
|
+
// ---- Mutations (pure) ----------------------------------------------------
|
|
150
|
+
function insertText(s, text) {
|
|
151
|
+
return {
|
|
152
|
+
...s,
|
|
153
|
+
value: s.value.slice(0, s.cursor) + text + s.value.slice(s.cursor),
|
|
154
|
+
cursor: s.cursor + text.length,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function deleteChar(s) {
|
|
158
|
+
if (s.cursor >= s.value.length) {
|
|
159
|
+
if (s.cursor > 0) {
|
|
160
|
+
return {
|
|
161
|
+
...s,
|
|
162
|
+
value: s.value.slice(0, s.cursor - 1) + s.value.slice(s.cursor),
|
|
163
|
+
cursor: s.cursor - 1,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return s;
|
|
167
|
+
}
|
|
168
|
+
return { ...s, value: s.value.slice(0, s.cursor) + s.value.slice(s.cursor + 1) };
|
|
169
|
+
}
|
|
170
|
+
function deleteLine(s) {
|
|
171
|
+
const start = lineStart(s.value, s.cursor);
|
|
172
|
+
let end = lineEnd(s.value, s.cursor);
|
|
173
|
+
if (s.value[end] === '\n')
|
|
174
|
+
end += 1;
|
|
175
|
+
const value = s.value.slice(0, start) + s.value.slice(end);
|
|
176
|
+
return { ...s, value, cursor: Math.min(start, value.length) };
|
|
177
|
+
}
|
|
178
|
+
function openLine(s, dir) {
|
|
179
|
+
if (dir === 1) {
|
|
180
|
+
const end = lineEnd(s.value, s.cursor);
|
|
181
|
+
return { ...s, value: s.value.slice(0, end) + '\n' + s.value.slice(end), cursor: end + 1, mode: 'insert' };
|
|
182
|
+
}
|
|
183
|
+
const start = lineStart(s.value, s.cursor);
|
|
184
|
+
return { ...s, value: s.value.slice(0, start) + '\n' + s.value.slice(start), cursor: start, mode: 'insert' };
|
|
185
|
+
}
|
|
186
|
+
function moveVertical(s, dir) {
|
|
187
|
+
const { value, cursor } = s;
|
|
188
|
+
const col = cursor - lineStart(value, cursor);
|
|
189
|
+
if (dir === -1) {
|
|
190
|
+
const ps = lineStart(value, cursor);
|
|
191
|
+
if (ps === 0)
|
|
192
|
+
return s;
|
|
193
|
+
const prevStart = lineStart(value, ps - 1);
|
|
194
|
+
const prevLen = ps - 1 - prevStart;
|
|
195
|
+
return { ...s, cursor: prevStart + Math.min(col, prevLen) };
|
|
196
|
+
}
|
|
197
|
+
const le = lineEnd(value, cursor);
|
|
198
|
+
if (le >= value.length)
|
|
199
|
+
return s;
|
|
200
|
+
const nextStart = le + 1;
|
|
201
|
+
const nextLen = lineEnd(value, nextStart) - nextStart;
|
|
202
|
+
return { ...s, cursor: nextStart + Math.min(col, nextLen) };
|
|
203
|
+
}
|
|
204
|
+
// ---- Motion helpers (pure) -----------------------------------------------
|
|
205
|
+
function lineStart(v, pos) {
|
|
206
|
+
const nl = v.lastIndexOf('\n', pos - 1);
|
|
207
|
+
return nl === -1 ? 0 : nl + 1;
|
|
208
|
+
}
|
|
209
|
+
function lineEnd(v, pos) {
|
|
210
|
+
const nl = v.indexOf('\n', pos);
|
|
211
|
+
return nl === -1 ? v.length : nl;
|
|
212
|
+
}
|
|
213
|
+
function lastLineStart(v) {
|
|
214
|
+
const nl = v.lastIndexOf('\n');
|
|
215
|
+
return nl === -1 ? 0 : nl + 1;
|
|
216
|
+
}
|
|
217
|
+
function wordForward(v, pos) {
|
|
218
|
+
let i = pos;
|
|
219
|
+
const n = v.length;
|
|
220
|
+
if (i >= n)
|
|
221
|
+
return n;
|
|
222
|
+
const isWord = WORD.test(v[i]);
|
|
223
|
+
while (i < n && !/\s/.test(v[i]) && WORD.test(v[i]) === isWord)
|
|
224
|
+
i++;
|
|
225
|
+
while (i < n && /\s/.test(v[i]))
|
|
226
|
+
i++;
|
|
227
|
+
return i;
|
|
228
|
+
}
|
|
229
|
+
function wordBack(v, pos) {
|
|
230
|
+
let i = pos - 1;
|
|
231
|
+
while (i > 0 && /\s/.test(v[i]))
|
|
232
|
+
i--;
|
|
233
|
+
if (i <= 0)
|
|
234
|
+
return 0;
|
|
235
|
+
const isWord = WORD.test(v[i]);
|
|
236
|
+
while (i > 0 && !/\s/.test(v[i - 1]) && WORD.test(v[i - 1]) === isWord)
|
|
237
|
+
i--;
|
|
238
|
+
return Math.max(0, i);
|
|
239
|
+
}
|
|
240
|
+
function wordEnd(v, pos) {
|
|
241
|
+
let i = pos + 1;
|
|
242
|
+
const n = v.length;
|
|
243
|
+
while (i < n && /\s/.test(v[i]))
|
|
244
|
+
i++;
|
|
245
|
+
if (i >= n)
|
|
246
|
+
return Math.max(0, n - 1);
|
|
247
|
+
const isWord = WORD.test(v[i]);
|
|
248
|
+
while (i + 1 < n && !/\s/.test(v[i + 1]) && WORD.test(v[i + 1]) === isWord)
|
|
249
|
+
i++;
|
|
250
|
+
return i;
|
|
251
|
+
}
|
|
252
|
+
/** Insert a visible cursor glyph and split into display lines. */
|
|
253
|
+
function renderWithCursor(display, cursor, mode) {
|
|
254
|
+
const glyph = mode === 'normal' ? '█' : '▏';
|
|
255
|
+
let out;
|
|
256
|
+
if (mode === 'normal' && cursor < display.length && display[cursor] !== '\n') {
|
|
257
|
+
out = display.slice(0, cursor) + glyph + display.slice(cursor + 1);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
out = display.slice(0, cursor) + glyph + display.slice(cursor);
|
|
261
|
+
}
|
|
262
|
+
return out.split('\n');
|
|
90
263
|
}
|