@profullstack/r3q 0.1.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.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/bin/r3q.mjs +2 -0
- package/dist/collection.d.ts +33 -0
- package/dist/collection.js +125 -0
- package/dist/highlight.d.ts +33 -0
- package/dist/highlight.js +104 -0
- package/dist/main.d.ts +32 -0
- package/dist/main.js +251 -0
- package/dist/send.d.ts +24 -0
- package/dist/send.js +62 -0
- package/package.json +51 -0
- package/src/collection.ts +135 -0
- package/src/highlight.ts +132 -0
- package/src/main.ts +251 -0
- package/src/send.ts +76 -0
package/dist/send.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending a request and describing what came back.
|
|
3
|
+
*
|
|
4
|
+
* The whole exchange is kept — status, headers, timing, body — because the
|
|
5
|
+
* point of a terminal REST client is inspecting the response, not just getting
|
|
6
|
+
* an exit code.
|
|
7
|
+
*/
|
|
8
|
+
import type { RequestFile } from "./collection.ts";
|
|
9
|
+
export interface Exchange {
|
|
10
|
+
status: number;
|
|
11
|
+
statusText: string;
|
|
12
|
+
headers: [string, string][];
|
|
13
|
+
body: string;
|
|
14
|
+
/** Round trip in milliseconds. */
|
|
15
|
+
ms: number;
|
|
16
|
+
bytes: number;
|
|
17
|
+
contentType: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function send(request: RequestFile, timeoutMs?: number): Promise<Exchange>;
|
|
21
|
+
/** Pretty-print JSON, leaving anything else exactly as it arrived. */
|
|
22
|
+
export declare function formatBody(body: string, contentType: string): string;
|
|
23
|
+
/** The colour family for a status code, as a theme key. */
|
|
24
|
+
export declare function statusKind(status: number): "success" | "accent" | "warning" | "danger" | "muted";
|
package/dist/send.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export async function send(request, timeoutMs = 30_000) {
|
|
2
|
+
const started = performance.now();
|
|
3
|
+
const empty = (error) => ({
|
|
4
|
+
status: 0, statusText: "", headers: [], body: "",
|
|
5
|
+
ms: performance.now() - started, bytes: 0, contentType: "", error,
|
|
6
|
+
});
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch(request.url, {
|
|
11
|
+
method: request.method,
|
|
12
|
+
headers: request.headers,
|
|
13
|
+
body: request.body,
|
|
14
|
+
signal: controller.signal,
|
|
15
|
+
redirect: "follow",
|
|
16
|
+
});
|
|
17
|
+
const body = await response.text();
|
|
18
|
+
return {
|
|
19
|
+
status: response.status,
|
|
20
|
+
statusText: response.statusText,
|
|
21
|
+
headers: [...response.headers.entries()].sort((a, b) => a[0].localeCompare(b[0])),
|
|
22
|
+
body,
|
|
23
|
+
ms: performance.now() - started,
|
|
24
|
+
bytes: new TextEncoder().encode(body).length,
|
|
25
|
+
contentType: response.headers.get("content-type") ?? "",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (controller.signal.aborted)
|
|
30
|
+
return empty(`timed out after ${timeoutMs}ms`);
|
|
31
|
+
return empty(error instanceof Error ? error.message : String(error));
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Pretty-print JSON, leaving anything else exactly as it arrived. */
|
|
38
|
+
export function formatBody(body, contentType) {
|
|
39
|
+
if (!/\bjson\b/i.test(contentType))
|
|
40
|
+
return body;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.stringify(JSON.parse(body), null, 2);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A content-type that lies is common enough not to be an error.
|
|
46
|
+
return body;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** The colour family for a status code, as a theme key. */
|
|
50
|
+
export function statusKind(status) {
|
|
51
|
+
if (status === 0)
|
|
52
|
+
return "danger";
|
|
53
|
+
if (status < 200)
|
|
54
|
+
return "muted";
|
|
55
|
+
if (status < 300)
|
|
56
|
+
return "success";
|
|
57
|
+
if (status < 400)
|
|
58
|
+
return "accent";
|
|
59
|
+
if (status < 500)
|
|
60
|
+
return "warning";
|
|
61
|
+
return "danger";
|
|
62
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@profullstack/r3q",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A REST client for your terminal. Requests are files you can commit; the TUI is just a good way to look at them.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://github.com/profullstack/r3q",
|
|
8
|
+
"bin": {
|
|
9
|
+
"r3q": "./bin/r3q.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.1",
|
|
20
|
+
"node": ">=22.6"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"start": "bun src/main.ts",
|
|
24
|
+
"build": "bun x tsc -p tsconfig.json",
|
|
25
|
+
"typecheck": "bun x tsc -p tsconfig.json --noEmit",
|
|
26
|
+
"test": "bun test test",
|
|
27
|
+
"prepublishOnly": "bun run build"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@profullstack/hqtui": "^0.5.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^26",
|
|
34
|
+
"typescript": "^7.0.2"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"rest",
|
|
38
|
+
"http",
|
|
39
|
+
"client",
|
|
40
|
+
"tui",
|
|
41
|
+
"terminal",
|
|
42
|
+
"hqtui"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/profullstack/r3q.git"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A collection is a directory of request files. Nothing is hidden in a binary
|
|
3
|
+
* workspace: a request is a file, so it diffs, reviews and merges like the rest
|
|
4
|
+
* of the repository it lives in.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join, relative, sep } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface RequestFile {
|
|
10
|
+
/** Path relative to the collection root, used as the display name and id. */
|
|
11
|
+
id: string;
|
|
12
|
+
path: string;
|
|
13
|
+
method: string;
|
|
14
|
+
url: string;
|
|
15
|
+
headers: Record<string, string>;
|
|
16
|
+
body?: string;
|
|
17
|
+
/** Parse errors are carried rather than thrown: one bad file is not a crash. */
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const METHODS = new Set([
|
|
22
|
+
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The `.http` format, as understood by every editor that speaks it:
|
|
27
|
+
*
|
|
28
|
+
* POST https://api.example.com/things
|
|
29
|
+
* Content-Type: application/json
|
|
30
|
+
*
|
|
31
|
+
* {"name": "thing"}
|
|
32
|
+
*
|
|
33
|
+
* A blank line ends the headers and starts the body. `#` and `//` are comments
|
|
34
|
+
* before the request line, so a file can explain itself.
|
|
35
|
+
*/
|
|
36
|
+
export function parseRequest(source: string, id: string, path: string): RequestFile {
|
|
37
|
+
const fail = (error: string): RequestFile =>
|
|
38
|
+
({ id, path, method: "GET", url: "", headers: {}, error });
|
|
39
|
+
|
|
40
|
+
const lines = source.split(/\r?\n/);
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < lines.length) {
|
|
43
|
+
const line = (lines[i] ?? "").trim();
|
|
44
|
+
if (line !== "" && !line.startsWith("#") && !line.startsWith("//")) break;
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
if (i >= lines.length) return fail("no request line");
|
|
48
|
+
|
|
49
|
+
const [method, ...rest] = (lines[i] as string).trim().split(/\s+/);
|
|
50
|
+
if (!method || !METHODS.has(method.toUpperCase())) {
|
|
51
|
+
return fail(`unknown method ${JSON.stringify(method ?? "")}`);
|
|
52
|
+
}
|
|
53
|
+
const url = rest.join(" ").trim();
|
|
54
|
+
if (url === "") return fail("no URL");
|
|
55
|
+
i++;
|
|
56
|
+
|
|
57
|
+
const headers: Record<string, string> = {};
|
|
58
|
+
for (; i < lines.length; i++) {
|
|
59
|
+
const line = lines[i] as string;
|
|
60
|
+
if (line.trim() === "") { i++; break; }
|
|
61
|
+
const colon = line.indexOf(":");
|
|
62
|
+
if (colon <= 0) return fail(`malformed header: ${line.trim()}`);
|
|
63
|
+
headers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const body = lines.slice(i).join("\n").trim();
|
|
67
|
+
return {
|
|
68
|
+
id, path,
|
|
69
|
+
method: method.toUpperCase(),
|
|
70
|
+
url,
|
|
71
|
+
headers,
|
|
72
|
+
body: body === "" ? undefined : body,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every `.http` file under `root`, depth first, in a stable order. */
|
|
77
|
+
export function loadCollection(root: string): RequestFile[] {
|
|
78
|
+
const out: RequestFile[] = [];
|
|
79
|
+
const walk = (dir: string): void => {
|
|
80
|
+
let entries: string[];
|
|
81
|
+
try {
|
|
82
|
+
entries = readdirSync(dir).sort();
|
|
83
|
+
} catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
88
|
+
const full = join(dir, entry);
|
|
89
|
+
let stats;
|
|
90
|
+
try {
|
|
91
|
+
stats = statSync(full);
|
|
92
|
+
} catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (stats.isDirectory()) {
|
|
96
|
+
walk(full);
|
|
97
|
+
} else if (entry.endsWith(".http")) {
|
|
98
|
+
const id = relative(root, full).split(sep).join("/");
|
|
99
|
+
try {
|
|
100
|
+
out.push(parseRequest(readFileSync(full, "utf8"), id, full));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
out.push({
|
|
103
|
+
id, path: full, method: "GET", url: "", headers: {},
|
|
104
|
+
error: error instanceof Error ? error.message : String(error),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
walk(root);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Substitute `{{name}}` from the environment.
|
|
116
|
+
*
|
|
117
|
+
* Values come from a `.env`-shaped file or the process environment, so secrets
|
|
118
|
+
* stay out of the committed request files.
|
|
119
|
+
*/
|
|
120
|
+
export function interpolate(text: string, vars: Record<string, string>): string {
|
|
121
|
+
return text.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (whole, name: string) => vars[name] ?? whole);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveRequest(request: RequestFile, vars: Record<string, string>): RequestFile {
|
|
125
|
+
const headers: Record<string, string> = {};
|
|
126
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
127
|
+
headers[interpolate(key, vars)] = interpolate(value, vars);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
...request,
|
|
131
|
+
url: interpolate(request.url, vars),
|
|
132
|
+
headers,
|
|
133
|
+
body: request.body === undefined ? undefined : interpolate(request.body, vars),
|
|
134
|
+
};
|
|
135
|
+
}
|
package/src/highlight.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON syntax highlighting, as spans.
|
|
3
|
+
*
|
|
4
|
+
* A tokeniser rather than a set of regexes over each line: a regex cannot tell
|
|
5
|
+
* a key from a string value, and it colours the inside of a string that happens
|
|
6
|
+
* to contain a brace. Both look fine on the example you tested and wrong on
|
|
7
|
+
* real API output.
|
|
8
|
+
*/
|
|
9
|
+
import type { Span, SpanLine } from "@profullstack/hqtui";
|
|
10
|
+
|
|
11
|
+
export interface JsonPalette {
|
|
12
|
+
key: number;
|
|
13
|
+
string: number;
|
|
14
|
+
number: number;
|
|
15
|
+
boolean: number;
|
|
16
|
+
null: number;
|
|
17
|
+
punctuation: number;
|
|
18
|
+
plain: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Kind = keyof JsonPalette;
|
|
22
|
+
|
|
23
|
+
export interface Token {
|
|
24
|
+
text: string;
|
|
25
|
+
kind: Kind;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const WHITESPACE = /\s/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split JSON into coloured tokens. Anything unparseable is emitted verbatim as
|
|
32
|
+
* `plain`, so malformed output stays readable rather than being swallowed.
|
|
33
|
+
*/
|
|
34
|
+
export function tokenizeJson(source: string): Token[] {
|
|
35
|
+
const out: Token[] = [];
|
|
36
|
+
let i = 0;
|
|
37
|
+
|
|
38
|
+
const push = (text: string, kind: Kind): void => {
|
|
39
|
+
if (text !== "") out.push({ text, kind });
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
while (i < source.length) {
|
|
43
|
+
const ch = source[i] as string;
|
|
44
|
+
|
|
45
|
+
if (WHITESPACE.test(ch)) {
|
|
46
|
+
let j = i;
|
|
47
|
+
while (j < source.length && WHITESPACE.test(source[j] as string)) j++;
|
|
48
|
+
push(source.slice(i, j), "plain");
|
|
49
|
+
i = j;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (ch === '"') {
|
|
54
|
+
let j = i + 1;
|
|
55
|
+
while (j < source.length) {
|
|
56
|
+
const c = source[j] as string;
|
|
57
|
+
if (c === "\\") { j += 2; continue; }
|
|
58
|
+
if (c === '"') { j++; break; }
|
|
59
|
+
j++;
|
|
60
|
+
}
|
|
61
|
+
const text = source.slice(i, j);
|
|
62
|
+
// A string is a key when the next non-space character is a colon. This is
|
|
63
|
+
// the whole reason for tokenising rather than pattern matching.
|
|
64
|
+
let k = j;
|
|
65
|
+
while (k < source.length && WHITESPACE.test(source[k] as string)) k++;
|
|
66
|
+
push(text, source[k] === ":" ? "key" : "string");
|
|
67
|
+
i = j;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (ch === "-" || (ch >= "0" && ch <= "9")) {
|
|
72
|
+
let j = i;
|
|
73
|
+
if (source[j] === "-") j++;
|
|
74
|
+
while (j < source.length && /[0-9.eE+\-]/.test(source[j] as string)) j++;
|
|
75
|
+
push(source.slice(i, j), "number");
|
|
76
|
+
i = j;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (source.startsWith("true", i) || source.startsWith("false", i)) {
|
|
81
|
+
const word = source.startsWith("true", i) ? "true" : "false";
|
|
82
|
+
push(word, "boolean");
|
|
83
|
+
i += word.length;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (source.startsWith("null", i)) {
|
|
88
|
+
push("null", "null");
|
|
89
|
+
i += 4;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if ("{}[],:".includes(ch)) {
|
|
94
|
+
push(ch, "punctuation");
|
|
95
|
+
i++;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Something unexpected: emit one character and keep going rather than
|
|
100
|
+
// giving up on the rest of the document.
|
|
101
|
+
push(ch, "plain");
|
|
102
|
+
i++;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Highlighted JSON, one SpanLine per line, ready for ui.text(). */
|
|
108
|
+
export function highlightJson(source: string, palette: JsonPalette): SpanLine[] {
|
|
109
|
+
const lines: SpanLine[] = [];
|
|
110
|
+
let current: SpanLine = [];
|
|
111
|
+
for (const token of tokenizeJson(source)) {
|
|
112
|
+
const parts = token.text.split("\n");
|
|
113
|
+
parts.forEach((part, index) => {
|
|
114
|
+
if (index > 0) { lines.push(current); current = []; }
|
|
115
|
+
if (part !== "") current.push({ text: part, fg: palette[token.kind] } satisfies Span);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
lines.push(current);
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Body text as span lines: highlighted when it is JSON, plain otherwise. */
|
|
123
|
+
export function highlightBody(
|
|
124
|
+
body: string,
|
|
125
|
+
contentType: string,
|
|
126
|
+
palette: JsonPalette,
|
|
127
|
+
): SpanLine[] {
|
|
128
|
+
if (!/\bjson\b/i.test(contentType)) {
|
|
129
|
+
return body.split("\n").map((line) => (line === "" ? [] : [{ text: line, fg: palette.plain }]));
|
|
130
|
+
}
|
|
131
|
+
return highlightJson(body, palette);
|
|
132
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* r3q — a REST client for your terminal.
|
|
3
|
+
*
|
|
4
|
+
* bunx @profullstack/r3q # the collection in the working directory
|
|
5
|
+
* bunx @profullstack/r3q ./api # a collection somewhere else
|
|
6
|
+
*
|
|
7
|
+
* Three panes: the collection, the request, the response. Enter sends.
|
|
8
|
+
*/
|
|
9
|
+
import { createApp, themes, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
|
|
10
|
+
import { loadCollection, resolveRequest, type RequestFile } from "./collection.ts";
|
|
11
|
+
import { formatBody, send, statusKind, type Exchange } from "./send.ts";
|
|
12
|
+
import { highlightBody, type JsonPalette } from "./highlight.ts";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
|
|
16
|
+
export interface State {
|
|
17
|
+
requests: RequestFile[];
|
|
18
|
+
selected: number;
|
|
19
|
+
offset: number;
|
|
20
|
+
bodyOffset: number;
|
|
21
|
+
exchange?: Exchange;
|
|
22
|
+
sending: boolean;
|
|
23
|
+
pane: "collection" | "response";
|
|
24
|
+
vars: Record<string, string>;
|
|
25
|
+
root: string;
|
|
26
|
+
note: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Variables come from `.env` beside the collection, then the environment. */
|
|
30
|
+
function loadVars(root: string): Record<string, string> {
|
|
31
|
+
const vars: Record<string, string> = {};
|
|
32
|
+
try {
|
|
33
|
+
for (const line of readFileSync(resolve(root, ".env"), "utf8").split(/\r?\n/)) {
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
36
|
+
const eq = trimmed.indexOf("=");
|
|
37
|
+
if (eq <= 0) continue;
|
|
38
|
+
vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// No .env is the normal case, not a problem.
|
|
42
|
+
}
|
|
43
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
44
|
+
if (value !== undefined && !(key in vars)) vars[key] = value;
|
|
45
|
+
}
|
|
46
|
+
return vars;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createState(root: string): State {
|
|
50
|
+
return {
|
|
51
|
+
requests: loadCollection(root),
|
|
52
|
+
selected: 0,
|
|
53
|
+
offset: 0,
|
|
54
|
+
bodyOffset: 0,
|
|
55
|
+
sending: false,
|
|
56
|
+
pane: "collection",
|
|
57
|
+
vars: loadVars(root),
|
|
58
|
+
root,
|
|
59
|
+
note: "",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const methodColor = (theme: Record<string, number>, method: string): number => ({
|
|
64
|
+
GET: theme.success, POST: theme.accent, PUT: theme.warning,
|
|
65
|
+
PATCH: theme.warning, DELETE: theme.danger,
|
|
66
|
+
}[method] ?? theme.secondary) as number;
|
|
67
|
+
|
|
68
|
+
async function main(): Promise<void> {
|
|
69
|
+
const root = resolve(process.argv[2] ?? ".");
|
|
70
|
+
const state = createState(root);
|
|
71
|
+
|
|
72
|
+
const app = await createApp({ theme: themes.dark, title: "r3q", quitKeys: ["ctrl+c"] });
|
|
73
|
+
|
|
74
|
+
const current = (): RequestFile | undefined => state.requests[state.selected];
|
|
75
|
+
|
|
76
|
+
const fire = async (): Promise<void> => {
|
|
77
|
+
const request = current();
|
|
78
|
+
if (!request || state.sending) return;
|
|
79
|
+
if (request.error) { state.note = request.error; app.invalidate(); return; }
|
|
80
|
+
state.sending = true;
|
|
81
|
+
state.note = "";
|
|
82
|
+
state.exchange = undefined;
|
|
83
|
+
state.bodyOffset = 0;
|
|
84
|
+
app.invalidate();
|
|
85
|
+
state.exchange = await send(resolveRequest(request, state.vars));
|
|
86
|
+
state.sending = false;
|
|
87
|
+
app.invalidate();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
app.on("key", (event: KeyEvent) => {
|
|
91
|
+
switch (event.key) {
|
|
92
|
+
case "q": app.quit(); return;
|
|
93
|
+
case "tab": state.pane = state.pane === "collection" ? "response" : "collection"; return;
|
|
94
|
+
case "r": state.requests = loadCollection(state.root); state.note = "reloaded"; return;
|
|
95
|
+
case "enter": void fire(); return;
|
|
96
|
+
case "up":
|
|
97
|
+
if (state.pane === "collection") state.selected = Math.max(0, state.selected - 1);
|
|
98
|
+
else state.bodyOffset = Math.max(0, state.bodyOffset - 1);
|
|
99
|
+
return;
|
|
100
|
+
case "down":
|
|
101
|
+
if (state.pane === "collection") {
|
|
102
|
+
state.selected = Math.min(state.requests.length - 1, state.selected + 1);
|
|
103
|
+
} else state.bodyOffset += 1;
|
|
104
|
+
return;
|
|
105
|
+
case "pageup": state.bodyOffset = Math.max(0, state.bodyOffset - 20); return;
|
|
106
|
+
case "pagedown": state.bodyOffset += 20; return;
|
|
107
|
+
case "home": state.bodyOffset = 0; return;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
app.render((args) => view(args, state));
|
|
112
|
+
await app.start();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
/** JSON token colours, drawn from the active theme rather than hard-coded. */
|
|
117
|
+
export function jsonPalette(theme: Theme): JsonPalette {
|
|
118
|
+
return {
|
|
119
|
+
key: theme.accent,
|
|
120
|
+
string: theme.success,
|
|
121
|
+
number: theme.warning,
|
|
122
|
+
boolean: theme.secondary,
|
|
123
|
+
null: theme.muted,
|
|
124
|
+
punctuation: theme.muted,
|
|
125
|
+
plain: theme.foreground,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function view(
|
|
130
|
+
{ ui, theme, height }: { ui: Container; theme: Theme; height: number },
|
|
131
|
+
state: State,
|
|
132
|
+
): void {
|
|
133
|
+
{
|
|
134
|
+
ui.row({ size: 1 }, (header) => {
|
|
135
|
+
header.text(" r3q", { fg: theme.title, bold: true, size: 6 });
|
|
136
|
+
header.text(state.root, { fg: theme.muted });
|
|
137
|
+
header.text(
|
|
138
|
+
`${state.requests.length} requests Tab panes Enter send r reload q quit `,
|
|
139
|
+
{ fg: theme.muted, align: "right" },
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
ui.row({ size: height - 2, gap: 1 }, (row) => {
|
|
144
|
+
row.panel({
|
|
145
|
+
title: "Collection",
|
|
146
|
+
width: "0.9fr",
|
|
147
|
+
borderColor: state.pane === "collection" ? theme.borderFocused : theme.border,
|
|
148
|
+
}, (p) => {
|
|
149
|
+
if (state.requests.length === 0) {
|
|
150
|
+
p.label(`No .http files under ${state.root}`);
|
|
151
|
+
p.label("Create one and press r to reload.");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
p.table({
|
|
155
|
+
rows: state.requests.map((r) => ({
|
|
156
|
+
method: r.error ? "ERR" : r.method,
|
|
157
|
+
name: r.id,
|
|
158
|
+
})),
|
|
159
|
+
selected: state.selected,
|
|
160
|
+
offset: state.offset,
|
|
161
|
+
followSelection: true,
|
|
162
|
+
scrollbar: true,
|
|
163
|
+
onScroll: (delta) => { state.offset = Math.max(0, state.offset + delta); },
|
|
164
|
+
onSelectRow: (index) => { state.selected = state.offset + index; },
|
|
165
|
+
header: false,
|
|
166
|
+
columns: [
|
|
167
|
+
{ key: "method", title: "", width: 7, color: theme.secondary },
|
|
168
|
+
{ key: "name", title: "", min: 10, color: theme.foreground },
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
row.column({ width: "2fr", gap: 1 }, (right) => {
|
|
174
|
+
const request = state.requests[state.selected];
|
|
175
|
+
right.panel({ title: "Request", size: 9 }, (p) => {
|
|
176
|
+
if (!request) { p.label("Nothing selected."); return; }
|
|
177
|
+
if (request.error) {
|
|
178
|
+
p.text(`${request.id}: ${request.error}`, { fg: theme.danger, wrap: true });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const resolved = resolveRequest(request, state.vars);
|
|
182
|
+
p.row({ size: 1 }, (r) => {
|
|
183
|
+
r.badge({ text: resolved.method, color: methodColor(theme as never, resolved.method), size: 8 });
|
|
184
|
+
r.text(` ${resolved.url}`, { fg: theme.foreground });
|
|
185
|
+
});
|
|
186
|
+
p.spacer(1);
|
|
187
|
+
const entries = Object.entries(resolved.headers);
|
|
188
|
+
if (entries.length > 0) {
|
|
189
|
+
p.keyValues(entries.map(([k, v]) => ({ label: `${k}:`, value: v, color: theme.muted })));
|
|
190
|
+
} else {
|
|
191
|
+
p.label("No headers.");
|
|
192
|
+
}
|
|
193
|
+
if (resolved.body) {
|
|
194
|
+
p.divider({ label: "body" });
|
|
195
|
+
p.text(resolved.body, { fg: theme.foreground, wrap: true });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const exchange = state.exchange;
|
|
200
|
+
const subtitle = state.sending
|
|
201
|
+
? "sending…"
|
|
202
|
+
: exchange
|
|
203
|
+
? `${exchange.status || "—"} ${exchange.statusText} ${Math.round(exchange.ms)}ms ${exchange.bytes}B`
|
|
204
|
+
: "press Enter";
|
|
205
|
+
right.panel({
|
|
206
|
+
title: "Response",
|
|
207
|
+
subtitle,
|
|
208
|
+
subtitleColor: exchange ? theme[statusKind(exchange.status)] : theme.muted,
|
|
209
|
+
size: "1fr",
|
|
210
|
+
borderColor: state.pane === "response" ? theme.borderFocused : theme.border,
|
|
211
|
+
}, (p) => {
|
|
212
|
+
if (state.note !== "") p.text(state.note, { fg: theme.warning, size: 1 });
|
|
213
|
+
if (state.sending) { p.label("Waiting for the server…"); return; }
|
|
214
|
+
if (!exchange) { p.label("No response yet."); return; }
|
|
215
|
+
if (exchange.error) { p.text(exchange.error, { fg: theme.danger, wrap: true }); return; }
|
|
216
|
+
p.keyValues(
|
|
217
|
+
exchange.headers.slice(0, 4).map(([k, v]) => ({ label: `${k}:`, value: v, color: theme.muted })),
|
|
218
|
+
);
|
|
219
|
+
p.divider({ label: "body" });
|
|
220
|
+
const body = highlightBody(
|
|
221
|
+
formatBody(exchange.body, exchange.contentType),
|
|
222
|
+
exchange.contentType,
|
|
223
|
+
jsonPalette(theme),
|
|
224
|
+
);
|
|
225
|
+
// One text() call per line rather than one for the whole body: the
|
|
226
|
+
// pane scrolls by line, so slicing here is what makes bodyOffset work.
|
|
227
|
+
for (const line of body.slice(state.bodyOffset, state.bodyOffset + 400)) {
|
|
228
|
+
p.text(line.length === 0 ? " " : line, { size: 1 });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
ui.statusBar({
|
|
235
|
+
items: [
|
|
236
|
+
{ key: "Enter", label: "Send" },
|
|
237
|
+
{ key: "Tab", label: state.pane === "collection" ? "Collection" : "Response", active: true },
|
|
238
|
+
{ key: "↑↓", label: "Move" },
|
|
239
|
+
{ key: "r", label: "Reload" },
|
|
240
|
+
{ key: "q", label: "Quit" },
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (import.meta.main) {
|
|
247
|
+
main().catch((error) => {
|
|
248
|
+
console.error(error);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
});
|
|
251
|
+
}
|