@beast01/tcurl 1.0.0 → 1.0.2
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 +40 -15
- package/dist/app.js +49 -6
- package/dist/components/Help.js +29 -12
- package/dist/components/RequestEditor.js +26 -7
- package/dist/components/ResponseViewer.js +9 -4
- package/dist/components/TextEditor.js +251 -78
- package/dist/http/client.js +24 -5
- 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
|
|
|
@@ -85,11 +104,17 @@ tcurl --help
|
|
|
85
104
|
- **Auth** — Bearer token or HTTP Basic (username / password).
|
|
86
105
|
- **Redirects** — follow (with method downgrade on 301/302/303) or manual.
|
|
87
106
|
- **TLS control** — toggle certificate verification for self-signed hosts.
|
|
88
|
-
- **Timeouts** — per-request
|
|
107
|
+
- **Timeouts & cancellation** — per-request timeout in milliseconds (default
|
|
108
|
+
30 000; set `0` to disable). While a request is in flight, press `Esc` (or
|
|
109
|
+
`q`) to abort it immediately — even with no timeout set — and `Ctrl+C`
|
|
110
|
+
always quits, so a hung server can never lock you up.
|
|
89
111
|
- **Pretty responses** — JSON is auto-formatted; scrollable body & headers
|
|
90
112
|
with status, timing, and size.
|
|
91
113
|
- **Persistence** — requests are stored as JSON and reused across sessions.
|
|
92
114
|
- **Export** — copy any saved request as an equivalent `curl` command.
|
|
115
|
+
- **Vim/nvim motions** — `j`/`k`, `gg`/`G`, `h`/`l` navigation throughout, plus
|
|
116
|
+
a fully modal (INSERT / NORMAL) field editor with `w`/`b`/`e`, `0`/`$`,
|
|
117
|
+
`x`, `dd`, `o`/`O`, and `Ctrl+d`/`Ctrl+u` scrolling.
|
|
93
118
|
|
|
94
119
|
## Where is my data?
|
|
95
120
|
|
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,10 @@ 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);
|
|
42
|
+
// In-flight request: controller to abort it, token to ignore stale results.
|
|
43
|
+
const abortRef = useRef(null);
|
|
44
|
+
const sendToken = useRef(0);
|
|
41
45
|
const theme = getTheme(store.theme);
|
|
42
46
|
const requests = store.requests;
|
|
43
47
|
function persist(next) {
|
|
@@ -60,19 +64,41 @@ export function App({ initialStore, exitState, }) {
|
|
|
60
64
|
flashMessage('Set a URL first');
|
|
61
65
|
return;
|
|
62
66
|
}
|
|
67
|
+
const token = ++sendToken.current;
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
abortRef.current = controller;
|
|
63
70
|
setLoading(true);
|
|
64
71
|
setResponse(null);
|
|
65
|
-
const result = await executeRequest(config);
|
|
72
|
+
const result = await executeRequest(config, controller.signal);
|
|
73
|
+
// Ignore results from a request the user cancelled or superseded.
|
|
74
|
+
if (sendToken.current !== token)
|
|
75
|
+
return;
|
|
76
|
+
abortRef.current = null;
|
|
66
77
|
setResponse(result);
|
|
67
78
|
setLoading(false);
|
|
68
79
|
setMode('response');
|
|
69
80
|
}
|
|
70
|
-
|
|
81
|
+
function cancelSend() {
|
|
82
|
+
abortRef.current?.abort();
|
|
83
|
+
abortRef.current = null;
|
|
84
|
+
sendToken.current++; // invalidate the pending result
|
|
85
|
+
setLoading(false);
|
|
86
|
+
flashMessage('Request cancelled');
|
|
87
|
+
}
|
|
88
|
+
// Ctrl+C always quits, from any mode (aborting any in-flight request).
|
|
71
89
|
useInput((input, key) => {
|
|
72
90
|
if (key.ctrl && input === 'c') {
|
|
91
|
+
abortRef.current?.abort();
|
|
73
92
|
exit();
|
|
74
|
-
return;
|
|
75
93
|
}
|
|
94
|
+
});
|
|
95
|
+
// While a request is in flight, Esc (or q) cancels it.
|
|
96
|
+
useInput((input, key) => {
|
|
97
|
+
if (key.escape || input === 'q')
|
|
98
|
+
cancelSend();
|
|
99
|
+
}, { isActive: loading });
|
|
100
|
+
// Global keys — only in list mode and when idle.
|
|
101
|
+
useInput((input, key) => {
|
|
76
102
|
if (loading)
|
|
77
103
|
return;
|
|
78
104
|
if (input === '?') {
|
|
@@ -89,6 +115,22 @@ export function App({ initialStore, exitState, }) {
|
|
|
89
115
|
persist({ ...store, theme: next });
|
|
90
116
|
return;
|
|
91
117
|
}
|
|
118
|
+
// gg → first, G → last (vim).
|
|
119
|
+
if (pendingG.current) {
|
|
120
|
+
pendingG.current = false;
|
|
121
|
+
if (input === 'g') {
|
|
122
|
+
setSelected(0);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (input === 'g') {
|
|
127
|
+
pendingG.current = true;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (input === 'G') {
|
|
131
|
+
setSelected(Math.max(0, requests.length - 1));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
92
134
|
if (key.upArrow || input === 'k') {
|
|
93
135
|
setSelected((s) => Math.max(0, s - 1));
|
|
94
136
|
return;
|
|
@@ -161,8 +203,9 @@ export function App({ initialStore, exitState, }) {
|
|
|
161
203
|
: [];
|
|
162
204
|
return (React.createElement(Box, { flexDirection: "column", width: cols, minHeight: rows - 1 },
|
|
163
205
|
React.createElement(Header, { theme: theme, subtitle: mode === 'list' ? `${requests.length} saved · ` : '' }),
|
|
164
|
-
React.createElement(Box, { flexGrow: 1, flexDirection: "column", marginTop: 1 }, loading ? (React.createElement(Box, { flexGrow: 1, borderStyle: "round", borderColor: theme.border, paddingX: 1, alignItems: "center", justifyContent: "center" },
|
|
165
|
-
React.createElement(Spinner, { theme: theme, label: "Sending request\u2026" })
|
|
206
|
+
React.createElement(Box, { flexGrow: 1, flexDirection: "column", marginTop: 1 }, loading ? (React.createElement(Box, { flexGrow: 1, borderStyle: "round", borderColor: theme.border, paddingX: 1, alignItems: "center", justifyContent: "center", flexDirection: "column" },
|
|
207
|
+
React.createElement(Spinner, { theme: theme, label: "Sending request\u2026" }),
|
|
208
|
+
React.createElement(Text, { color: theme.muted }, "Esc or q to cancel"))) : mode === 'help' ? (React.createElement(Help, { theme: theme, onClose: () => setMode('list') })) : mode === 'editor' && working ? (React.createElement(RequestEditor, { theme: theme, request: working, isActive: mode === 'editor', onChange: onEditorChange, onSend: onEditorSend, onBack: onEditorBack })) : mode === 'response' && response ? (React.createElement(ResponseViewer, { theme: theme, response: response, isActive: mode === 'response', onBack: () => setMode('list'), width: cols, height: rows })) : (React.createElement(RequestList, { theme: theme, requests: requests, selectedIndex: selected }))),
|
|
166
209
|
React.createElement(Box, { justifyContent: "space-between" },
|
|
167
210
|
React.createElement(StatusBar, { theme: theme, hints: statusHints }),
|
|
168
211
|
flash ? (React.createElement(Box, { paddingX: 1 },
|
package/dist/components/Help.js
CHANGED
|
@@ -2,12 +2,14 @@ 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'],
|
|
12
|
+
['Esc / q', 'Cancel a request while it is sending'],
|
|
11
13
|
['d', 'Delete request'],
|
|
12
14
|
['y', 'Copy as curl (prints on exit)'],
|
|
13
15
|
['t', 'Cycle theme (gruvbox / ember)'],
|
|
@@ -16,23 +18,38 @@ const SECTIONS = [
|
|
|
16
18
|
],
|
|
17
19
|
},
|
|
18
20
|
{
|
|
19
|
-
title: 'Editor',
|
|
21
|
+
title: 'Editor (field navigation)',
|
|
20
22
|
rows: [
|
|
21
|
-
['
|
|
22
|
-
['
|
|
23
|
-
['
|
|
24
|
-
['
|
|
23
|
+
['j / k / Tab', 'Move between fields'],
|
|
24
|
+
['gg / G', 'First / last field'],
|
|
25
|
+
['h / l', 'Change method, body type, toggles'],
|
|
26
|
+
['i / a / Enter', 'Edit the focused field'],
|
|
25
27
|
['Esc', 'Back to list (auto-saves)'],
|
|
26
28
|
],
|
|
27
29
|
},
|
|
28
30
|
{
|
|
29
|
-
title: '
|
|
31
|
+
title: 'Field editor — modal (INSERT / NORMAL)',
|
|
30
32
|
rows: [
|
|
31
|
-
['
|
|
32
|
-
['
|
|
33
|
+
['Esc', 'INSERT → NORMAL'],
|
|
34
|
+
['i a I A o O', 'NORMAL → INSERT (various)'],
|
|
35
|
+
['h j k l', 'Move by char / line'],
|
|
36
|
+
['w / b / e', 'Word forward / back / end'],
|
|
37
|
+
['0 / $', 'Line start / end'],
|
|
38
|
+
['gg / G', 'Buffer start / end'],
|
|
39
|
+
['x / dd', 'Delete char / line'],
|
|
40
|
+
['Enter / Ctrl+S', 'Save field'],
|
|
41
|
+
['Esc (in NORMAL)', 'Cancel edit'],
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
title: 'Response (vim keys)',
|
|
46
|
+
rows: [
|
|
47
|
+
['j / k', 'Scroll down / up'],
|
|
48
|
+
['Ctrl+d / Ctrl+u', 'Half-page down / up'],
|
|
49
|
+
['Ctrl+f / Ctrl+b', 'Full-page down / up'],
|
|
33
50
|
['g / G', 'Top / bottom'],
|
|
34
|
-
['
|
|
35
|
-
['Esc', 'Back'],
|
|
51
|
+
['h / l / Tab', 'Switch body / headers'],
|
|
52
|
+
['q / Esc', 'Back'],
|
|
36
53
|
],
|
|
37
54
|
},
|
|
38
55
|
];
|
|
@@ -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
|
}
|
package/dist/http/client.js
CHANGED
|
@@ -141,14 +141,28 @@ function doRequest(opts, redirectsLeft) {
|
|
|
141
141
|
req.end();
|
|
142
142
|
});
|
|
143
143
|
}
|
|
144
|
-
/**
|
|
145
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Execute a request config and return a normalized result (never throws).
|
|
146
|
+
*
|
|
147
|
+
* The optional `externalSignal` lets the caller (the UI) cancel an in-flight
|
|
148
|
+
* request; when it fires the result carries `error: "Request cancelled"`.
|
|
149
|
+
* A per-request timeout aborts independently.
|
|
150
|
+
*/
|
|
151
|
+
export async function executeRequest(config, externalSignal) {
|
|
146
152
|
const start = Date.now();
|
|
147
153
|
const controller = new AbortController();
|
|
148
154
|
let timer;
|
|
149
155
|
if (config.timeoutMs > 0) {
|
|
150
156
|
timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
151
157
|
}
|
|
158
|
+
// Bridge an external (user) cancellation into our controller.
|
|
159
|
+
const onExternalAbort = () => controller.abort();
|
|
160
|
+
if (externalSignal) {
|
|
161
|
+
if (externalSignal.aborted)
|
|
162
|
+
controller.abort();
|
|
163
|
+
else
|
|
164
|
+
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
|
165
|
+
}
|
|
152
166
|
try {
|
|
153
167
|
const url = buildUrl(config);
|
|
154
168
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
@@ -168,6 +182,7 @@ export async function executeRequest(config) {
|
|
|
168
182
|
}
|
|
169
183
|
catch (err) {
|
|
170
184
|
const aborted = err?.name === 'AbortError' || controller.signal.aborted;
|
|
185
|
+
const cancelled = externalSignal?.aborted ?? false;
|
|
171
186
|
return {
|
|
172
187
|
ok: false,
|
|
173
188
|
status: 0,
|
|
@@ -178,13 +193,17 @@ export async function executeRequest(config) {
|
|
|
178
193
|
durationMs: Date.now() - start,
|
|
179
194
|
sizeBytes: 0,
|
|
180
195
|
finalUrl: config.url,
|
|
181
|
-
error:
|
|
182
|
-
?
|
|
183
|
-
:
|
|
196
|
+
error: cancelled
|
|
197
|
+
? 'Request cancelled'
|
|
198
|
+
: aborted
|
|
199
|
+
? `Request timed out after ${config.timeoutMs}ms`
|
|
200
|
+
: String(err?.message ?? err),
|
|
184
201
|
};
|
|
185
202
|
}
|
|
186
203
|
finally {
|
|
187
204
|
if (timer)
|
|
188
205
|
clearTimeout(timer);
|
|
206
|
+
if (externalSignal)
|
|
207
|
+
externalSignal.removeEventListener('abort', onExternalAbort);
|
|
189
208
|
}
|
|
190
209
|
}
|