@deckbuilderkit/editor 0.0.0-stage → 0.1.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/package.json +53 -4
- package/src/ArchiveView.tsx +111 -0
- package/src/CodeEditor.tsx +265 -0
- package/src/CodeView.tsx +67 -0
- package/src/DiffView.tsx +139 -0
- package/src/EnvView.tsx +42 -0
- package/src/FilePreview.tsx +108 -0
- package/src/FontView.tsx +48 -0
- package/src/HarView.tsx +70 -0
- package/src/HexView.tsx +28 -0
- package/src/HtmlPreview.tsx +23 -0
- package/src/MarkdownEditor.tsx +90 -0
- package/src/MarkdownToc.tsx +135 -0
- package/src/MarkdownView.tsx +84 -0
- package/src/MediaView.tsx +17 -0
- package/src/NotebookView.tsx +74 -0
- package/src/PdfView.tsx +289 -0
- package/src/RequestEditor.tsx +419 -0
- package/src/ResponseView.tsx +89 -0
- package/src/SqlGraph.tsx +322 -0
- package/src/SqlSteps.tsx +487 -0
- package/src/SqliteView.tsx +68 -0
- package/src/SvgView.tsx +231 -0
- package/src/TableView.tsx +85 -0
- package/src/VarSuggest.tsx +125 -0
- package/src/curl-file.ts +70 -0
- package/src/editorOptions.ts +83 -0
- package/src/editorOptionsContext.ts +4 -0
- package/src/fileKinds.ts +69 -0
- package/src/http-client.ts +9 -0
- package/src/http-import.ts +594 -0
- package/src/http.ts +251 -0
- package/src/import-warning.ts +8 -0
- package/src/index.ts +56 -0
- package/src/languages.ts +107 -0
- package/src/markdown.ts +123 -0
- package/src/markdownLive.ts +289 -0
- package/src/options.ts +3 -0
- package/src/pdf-url.d.ts +4 -0
- package/src/sql/format.ts +637 -0
- package/src/sql/formatOptions.ts +71 -0
- package/src/sql/lineage.ts +365 -0
- package/src/sql/lint.ts +951 -0
- package/src/sql/markerHighlight.ts +50 -0
- package/src/sql/markers.ts +138 -0
- package/src/sql/outline.ts +662 -0
- package/src/sql/statements.ts +83 -0
- package/src/sql/tokens.ts +156 -0
- package/src/sql/xmlQuery.ts +249 -0
- package/src/sqlProblems.ts +40 -0
- package/src/table.ts +89 -0
- package/src/text-save.ts +28 -0
- package/src/theme.ts +86 -0
- package/src/toc.ts +11 -0
- package/src/variables.ts +123 -0
- package/README.md +0 -3
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { HTTP_MAX_FORM_FIELDS, HTTP_METHODS, HTTP_TIMEOUT_MAX_MS, HTTP_TIMEOUT_MIN_MS, type HttpMethod, type RestEnvVar, restVarRefs } from '@deckbuilderkit/server-core/protocol';
|
|
2
|
+
import { Icon, Spinner, t } from '@deckbuilderkit/ui';
|
|
3
|
+
import { type MouseEvent, type ReactNode, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { CodeEditor } from './CodeEditor';
|
|
5
|
+
import {
|
|
6
|
+
type HttpHeaderDraft,
|
|
7
|
+
type HttpRequestDraft,
|
|
8
|
+
type QueryParam,
|
|
9
|
+
authHeaderValue,
|
|
10
|
+
formatXml,
|
|
11
|
+
hasAuthHeader,
|
|
12
|
+
headersFromText,
|
|
13
|
+
headersToText,
|
|
14
|
+
parseAuthValue,
|
|
15
|
+
queryParams,
|
|
16
|
+
sendsBody,
|
|
17
|
+
timeoutFromSeconds,
|
|
18
|
+
withQueryParams,
|
|
19
|
+
} from './http';
|
|
20
|
+
import { VarInput, useVarSuggest } from './VarSuggest';
|
|
21
|
+
import { variableAutocomplete, variableClass, variableHighlight, variableInfo } from './variables';
|
|
22
|
+
|
|
23
|
+
type Tab = 'params' | 'headers' | 'auth' | 'body' | 'options';
|
|
24
|
+
const TABS: Tab[] = ['params', 'headers', 'auth', 'body', 'options'];
|
|
25
|
+
|
|
26
|
+
/** The Authorization value the Auth tab edits; the app keeps it (e.g. as a secret), the draft holds only `authKind`. */
|
|
27
|
+
export interface RequestAuthProps {
|
|
28
|
+
value: string;
|
|
29
|
+
onChange(value: string): void;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** Shown in the empty fields, e.g. when a saved value exists but is not loaded. */
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
/** Note under the fields (where the value is kept, why it is disabled). */
|
|
34
|
+
hint?: ReactNode;
|
|
35
|
+
/** A saved value exists that `value` does not hold (e.g. a write-only secret). */
|
|
36
|
+
hasSaved?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RequestEditorProps {
|
|
40
|
+
value: HttpRequestDraft;
|
|
41
|
+
onChange(draft: HttpRequestDraft): void;
|
|
42
|
+
onSend(): void;
|
|
43
|
+
onCancel?(): void;
|
|
44
|
+
sending: boolean;
|
|
45
|
+
/** Extra content under the headers list (the Headers tab only). */
|
|
46
|
+
headersFooter?: ReactNode;
|
|
47
|
+
/** Enables the Auth tab. */
|
|
48
|
+
auth?: RequestAuthProps;
|
|
49
|
+
/** The active environment's variables; given, `{{name}}` in the URL and body is marked defined or undefined. */
|
|
50
|
+
variables?: readonly RestEnvVar[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Method, URL, headers, body and options of one HTTP request; sending is up to the app. */
|
|
54
|
+
export function RequestEditor({ value, onChange, onSend, onCancel, sending, headersFooter, auth, variables }: RequestEditorProps) {
|
|
55
|
+
const [tab, setTab] = useState<Tab>('params');
|
|
56
|
+
const params = queryParams(value.url);
|
|
57
|
+
const set = (patch: Partial<HttpRequestDraft>) => onChange({ ...value, ...patch });
|
|
58
|
+
const enabledHeaders = value.headers.filter((h) => h.enabled && h.name.trim() !== '').length;
|
|
59
|
+
const canSend = value.url.trim() !== '';
|
|
60
|
+
const send = () => {
|
|
61
|
+
if (canSend && !sending) onSend();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="req-editor">
|
|
66
|
+
<div className="req-top">
|
|
67
|
+
<select className="req-method" value={value.method} onChange={(e) => set({ method: e.target.value as HttpMethod })} aria-label={t('http.method')}>
|
|
68
|
+
{HTTP_METHODS.map((m) => <option key={m} value={m}>{m}</option>)}
|
|
69
|
+
</select>
|
|
70
|
+
<UrlInput value={value.url} onChange={(url) => set({ url })} onEnter={send} variables={variables} />
|
|
71
|
+
{sending ? (
|
|
72
|
+
<button className="btn" onClick={onCancel} disabled={!onCancel}>{t('dialog.cancel')}</button>
|
|
73
|
+
) : (
|
|
74
|
+
<button className="btn btn-primary req-send" onClick={send} disabled={!canSend}>
|
|
75
|
+
<Icon name="send" /> {t('http.send')}
|
|
76
|
+
</button>
|
|
77
|
+
)}
|
|
78
|
+
{sending && <Spinner small />}
|
|
79
|
+
</div>
|
|
80
|
+
<div className="req-tabs" role="tablist">
|
|
81
|
+
{TABS.filter((id) => id !== 'auth' || auth).map((id) => (
|
|
82
|
+
<button key={id} role="tab" aria-selected={tab === id} className={`req-tab${tab === id ? ' active' : ''}`} onClick={() => setTab(id)}>
|
|
83
|
+
{t(`http.tab.${id}`)}
|
|
84
|
+
{id === 'params' && params.length > 0 && <span className="req-tab-count">{params.length}</span>}
|
|
85
|
+
{id === 'headers' && enabledHeaders > 0 && <span className="req-tab-count">{enabledHeaders}</span>}
|
|
86
|
+
</button>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
<div className="req-panel">
|
|
90
|
+
{tab === 'params' && <ParamsTab params={params} onChange={(next) => set({ url: withQueryParams(value.url, next) })} variables={variables} />}
|
|
91
|
+
{tab === 'headers' && (
|
|
92
|
+
<>
|
|
93
|
+
<HeadersTab headers={value.headers} onChange={(headers) => set({ headers })} authKind={authRowKind(value, auth)} variables={variables} />
|
|
94
|
+
{headersFooter}
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
{tab === 'auth' && auth && <AuthTab value={value} onChange={set} auth={auth} variables={variables} />}
|
|
98
|
+
{tab === 'body' && <BodyTab value={value} onChange={set} onSend={send} variables={variables} />}
|
|
99
|
+
{tab === 'options' && <OptionsTab value={value} onChange={set} />}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The URL field; with `variables`, a mirror over the input marks each `{{name}}` and hovering one shows its value. */
|
|
106
|
+
function UrlInput({ value, onChange, onEnter, variables }: { value: string; onChange(url: string): void; onEnter(): void; variables?: readonly RestEnvVar[] }) {
|
|
107
|
+
const input = useRef<HTMLInputElement>(null);
|
|
108
|
+
const mirror = useRef<HTMLDivElement>(null);
|
|
109
|
+
const refs = variables ? restVarRefs(value) : [];
|
|
110
|
+
const suggest = useVarSuggest(variables, input, onChange);
|
|
111
|
+
const sync = () => {
|
|
112
|
+
if (mirror.current && input.current) mirror.current.scrollLeft = input.current.scrollLeft;
|
|
113
|
+
};
|
|
114
|
+
useLayoutEffect(() => {
|
|
115
|
+
const i = input.current;
|
|
116
|
+
const m = mirror.current;
|
|
117
|
+
if (!i || !m) return;
|
|
118
|
+
const cs = getComputedStyle(i);
|
|
119
|
+
Object.assign(m.style, { padding: cs.padding, borderWidth: cs.borderWidth, font: cs.font, letterSpacing: cs.letterSpacing });
|
|
120
|
+
sync();
|
|
121
|
+
});
|
|
122
|
+
const segments: ReactNode[] = [];
|
|
123
|
+
let at = 0;
|
|
124
|
+
for (const r of refs) {
|
|
125
|
+
const info = variableInfo(r.name, variables!);
|
|
126
|
+
segments.push(value.slice(at, r.from), <span key={r.from} className={variableClass(info.defined)} data-title={info.title}>{value.slice(r.from, r.to)}</span>);
|
|
127
|
+
at = r.to;
|
|
128
|
+
}
|
|
129
|
+
segments.push(value.slice(at));
|
|
130
|
+
const hover = (e: MouseEvent<HTMLInputElement>) => {
|
|
131
|
+
let title = '';
|
|
132
|
+
for (const span of mirror.current?.querySelectorAll<HTMLElement>('[data-title]') ?? []) {
|
|
133
|
+
const r = span.getBoundingClientRect();
|
|
134
|
+
if (e.clientX >= r.left && e.clientX <= r.right) title = span.dataset.title ?? '';
|
|
135
|
+
}
|
|
136
|
+
e.currentTarget.title = title;
|
|
137
|
+
};
|
|
138
|
+
return (
|
|
139
|
+
<div className="req-url-wrap">
|
|
140
|
+
<input
|
|
141
|
+
ref={input}
|
|
142
|
+
className="req-url"
|
|
143
|
+
value={value}
|
|
144
|
+
placeholder="https://"
|
|
145
|
+
spellCheck={false}
|
|
146
|
+
aria-label={t('http.url')}
|
|
147
|
+
onChange={(e) => {
|
|
148
|
+
onChange(e.target.value);
|
|
149
|
+
suggest.update(e.target);
|
|
150
|
+
}}
|
|
151
|
+
onKeyDown={(e) => {
|
|
152
|
+
if (suggest.keyDown(e)) return;
|
|
153
|
+
if (e.key === 'Enter') onEnter();
|
|
154
|
+
}}
|
|
155
|
+
onBlur={suggest.close}
|
|
156
|
+
onScroll={sync}
|
|
157
|
+
onSelect={(e) => {
|
|
158
|
+
sync();
|
|
159
|
+
suggest.update(e.currentTarget);
|
|
160
|
+
}}
|
|
161
|
+
onMouseMove={refs.length ? hover : undefined}
|
|
162
|
+
/>
|
|
163
|
+
{refs.length > 0 && <div ref={mirror} className="req-url-mirror" aria-hidden="true">{segments}</div>}
|
|
164
|
+
{suggest.list}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const BLANK: HttpHeaderDraft = { name: '', value: '', enabled: true };
|
|
170
|
+
|
|
171
|
+
/** The Auth tab's kind when it sends an Authorization header the Headers tab does not list. */
|
|
172
|
+
function authRowKind(value: HttpRequestDraft, auth: RequestAuthProps | undefined): 'basic' | 'bearer' | undefined {
|
|
173
|
+
if (!auth || hasAuthHeader(value.headers)) return undefined;
|
|
174
|
+
const kind = value.authKind ?? parseAuthValue(auth.value).kind;
|
|
175
|
+
if (!kind) return undefined;
|
|
176
|
+
return auth.value !== '' || auth.hasSaved ? kind : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function HeadersTab({ headers, onChange, authKind, variables }: { headers: HttpHeaderDraft[]; onChange(h: HttpHeaderDraft[]): void; authKind?: 'basic' | 'bearer'; variables?: readonly RestEnvVar[] }) {
|
|
180
|
+
const [bulk, setBulk] = useState<string | null>(null);
|
|
181
|
+
const rows = [...headers, BLANK];
|
|
182
|
+
const toggle = (
|
|
183
|
+
<div className="req-body-bar">
|
|
184
|
+
<span className="spacer" />
|
|
185
|
+
<button
|
|
186
|
+
className={`btn btn-sm${bulk !== null ? ' btn-primary' : ' btn-ghost'}`}
|
|
187
|
+
aria-pressed={bulk !== null}
|
|
188
|
+
onClick={() => setBulk(bulk === null ? headersToText(headers) : null)}
|
|
189
|
+
>
|
|
190
|
+
{t('http.bulkEdit')}
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
if (bulk !== null) {
|
|
195
|
+
return (
|
|
196
|
+
<div className="req-headers">
|
|
197
|
+
{toggle}
|
|
198
|
+
<textarea
|
|
199
|
+
className="req-headers-bulk"
|
|
200
|
+
value={bulk}
|
|
201
|
+
rows={Math.max(6, bulk.split('\n').length + 1)}
|
|
202
|
+
spellCheck={false}
|
|
203
|
+
placeholder={t('http.bulkPlaceholder')}
|
|
204
|
+
aria-label={t('http.bulkEdit')}
|
|
205
|
+
onChange={(e) => {
|
|
206
|
+
setBulk(e.target.value);
|
|
207
|
+
onChange(headersFromText(e.target.value));
|
|
208
|
+
}}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const update = (i: number, patch: Partial<HttpHeaderDraft>) => {
|
|
214
|
+
const next = rows.map((h, j) => (j === i ? { ...h, ...patch } : h));
|
|
215
|
+
const last = next[next.length - 1];
|
|
216
|
+
onChange(last.name === '' && last.value === '' ? next.slice(0, -1) : next);
|
|
217
|
+
};
|
|
218
|
+
return (
|
|
219
|
+
<div className="req-headers">
|
|
220
|
+
{toggle}
|
|
221
|
+
{authKind && (
|
|
222
|
+
<div className="req-header-row req-header-auto" title={t('http.fromAuthTab')}>
|
|
223
|
+
<input type="checkbox" checked disabled aria-label={t('http.headerEnabled')} />
|
|
224
|
+
<input value="Authorization" readOnly disabled aria-label={t('http.headerName')} />
|
|
225
|
+
<input value={`${authKind === 'basic' ? 'Basic' : 'Bearer'} ••••`} readOnly disabled aria-label={t('http.headerValue')} />
|
|
226
|
+
<span className="muted">{t('http.fromAuthTab')}</span>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
{rows.map((h, i) => {
|
|
230
|
+
const isBlank = i === headers.length;
|
|
231
|
+
return (
|
|
232
|
+
<div key={i} className="req-header-row">
|
|
233
|
+
<input type="checkbox" checked={h.enabled} disabled={isBlank} aria-label={t('http.headerEnabled')} onChange={(e) => update(i, { enabled: e.target.checked })} />
|
|
234
|
+
<input value={h.name} placeholder={t('http.headerName')} spellCheck={false} aria-label={t('http.headerName')} onChange={(e) => update(i, { name: e.target.value })} />
|
|
235
|
+
<VarInput value={h.value} placeholder={t('http.headerValue')} spellCheck={false} aria-label={t('http.headerValue')} variables={variables} onValueChange={(v) => update(i, { value: v })} />
|
|
236
|
+
<button
|
|
237
|
+
className="btn btn-ghost btn-sm btn-icon"
|
|
238
|
+
disabled={isBlank}
|
|
239
|
+
title={t('http.removeHeader')}
|
|
240
|
+
aria-label={t('http.removeHeader')}
|
|
241
|
+
onClick={() => onChange(headers.filter((_, j) => j !== i))}
|
|
242
|
+
>
|
|
243
|
+
<Icon name="close" />
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
})}
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const BLANK_PARAM: QueryParam = { name: '', value: '' };
|
|
253
|
+
|
|
254
|
+
/** Rows of the URL query; the URL is the source of truth, so every edit rewrites it. */
|
|
255
|
+
function ParamsTab({ params, onChange, removeLabel = 'http.removeParam', max = Infinity, variables }: { params: QueryParam[]; onChange(p: QueryParam[]): void; removeLabel?: 'http.removeParam' | 'http.removeField'; max?: number; variables?: readonly RestEnvVar[] }) {
|
|
256
|
+
const rows = params.length < max ? [...params, BLANK_PARAM] : params;
|
|
257
|
+
const update = (i: number, patch: Partial<QueryParam>) => onChange(rows.map((p, j) => (j === i ? { ...p, ...patch } : p)));
|
|
258
|
+
return (
|
|
259
|
+
<div className="req-headers">
|
|
260
|
+
{rows.map((p, i) => {
|
|
261
|
+
const isBlank = i === params.length;
|
|
262
|
+
return (
|
|
263
|
+
<div key={i} className="req-header-row req-param-row">
|
|
264
|
+
<input value={p.name} placeholder={t('http.paramName')} spellCheck={false} aria-label={t('http.paramName')} onChange={(e) => update(i, { name: e.target.value })} />
|
|
265
|
+
<VarInput value={p.value} placeholder={t('http.paramValue')} spellCheck={false} aria-label={t('http.paramValue')} variables={variables} onValueChange={(v) => update(i, { value: v })} />
|
|
266
|
+
<button
|
|
267
|
+
className="btn btn-ghost btn-sm btn-icon"
|
|
268
|
+
disabled={isBlank}
|
|
269
|
+
title={t(removeLabel)}
|
|
270
|
+
aria-label={t(removeLabel)}
|
|
271
|
+
onClick={() => onChange(params.filter((_, j) => j !== i))}
|
|
272
|
+
>
|
|
273
|
+
<Icon name="close" />
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
})}
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function AuthTab({ value, onChange, auth, variables }: { value: HttpRequestDraft; onChange(patch: Partial<HttpRequestDraft>): void; auth: RequestAuthProps; variables?: readonly RestEnvVar[] }) {
|
|
283
|
+
const parts = parseAuthValue(auth.value);
|
|
284
|
+
const kind = value.authKind ?? parts.kind;
|
|
285
|
+
const placeholder = auth.value === '' ? auth.placeholder : undefined;
|
|
286
|
+
const setKind = (next: typeof kind) => {
|
|
287
|
+
onChange({ authKind: next });
|
|
288
|
+
if (auth.value !== '') auth.onChange('');
|
|
289
|
+
};
|
|
290
|
+
const edit = (patch: Partial<typeof parts>) => {
|
|
291
|
+
if (!kind) return;
|
|
292
|
+
if (value.authKind !== kind) onChange({ authKind: kind });
|
|
293
|
+
auth.onChange(authHeaderValue(kind, { ...parts, ...patch }));
|
|
294
|
+
};
|
|
295
|
+
return (
|
|
296
|
+
<div className="req-options req-auth">
|
|
297
|
+
<div className="req-body-bar">
|
|
298
|
+
{([undefined, 'basic', 'bearer'] as const).map((k) => (
|
|
299
|
+
<button
|
|
300
|
+
key={k ?? 'none'}
|
|
301
|
+
className={`btn btn-sm${kind === k ? ' btn-primary' : ' btn-ghost'}`}
|
|
302
|
+
aria-pressed={kind === k}
|
|
303
|
+
disabled={auth.disabled}
|
|
304
|
+
onClick={() => setKind(k)}
|
|
305
|
+
>
|
|
306
|
+
{t(`http.auth.${k ?? 'none'}`)}
|
|
307
|
+
</button>
|
|
308
|
+
))}
|
|
309
|
+
</div>
|
|
310
|
+
{kind === 'basic' && (
|
|
311
|
+
<>
|
|
312
|
+
<label className="field">
|
|
313
|
+
<span>{t('http.authUser')}</span>
|
|
314
|
+
<VarInput value={parts.user} placeholder={placeholder} disabled={auth.disabled} spellCheck={false} autoComplete="off" variables={variables} onValueChange={(user) => edit({ user })} />
|
|
315
|
+
</label>
|
|
316
|
+
<label className="field">
|
|
317
|
+
<span>{t('http.authPassword')}</span>
|
|
318
|
+
<VarInput type="password" value={parts.password} placeholder={placeholder} disabled={auth.disabled} autoComplete="off" variables={variables} onValueChange={(password) => edit({ password })} />
|
|
319
|
+
</label>
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
{kind === 'bearer' && (
|
|
323
|
+
<label className="field">
|
|
324
|
+
<span>{t('http.authToken')}</span>
|
|
325
|
+
<VarInput type="password" value={parts.token} placeholder={placeholder} disabled={auth.disabled} autoComplete="off" variables={variables} onValueChange={(token) => edit({ token })} />
|
|
326
|
+
</label>
|
|
327
|
+
)}
|
|
328
|
+
{auth.hint && <span className="req-hint">{auth.hint}</span>}
|
|
329
|
+
{hasAuthHeader(value.headers) && <span className="req-hint tone-warn">{t('http.authOverridden')}</span>}
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function BodyTab({ value, onChange, onSend, variables }: { value: HttpRequestDraft; onChange(patch: Partial<HttpRequestDraft>): void; onSend(): void; variables?: readonly RestEnvVar[] }) {
|
|
335
|
+
const [formatError, setFormatError] = useState<string | null>(null);
|
|
336
|
+
const highlight = useMemo(() => (variables ? [variableHighlight(variables), variableAutocomplete(variables)] : []), [variables]);
|
|
337
|
+
const bodyless = !sendsBody(value.method);
|
|
338
|
+
const format = () => {
|
|
339
|
+
try {
|
|
340
|
+
onChange({ body: value.bodyLang === 'xml' ? formatXml(value.body) : JSON.stringify(JSON.parse(value.body), null, 2) });
|
|
341
|
+
setFormatError(null);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
setFormatError(err instanceof Error ? err.message : String(err));
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
return (
|
|
347
|
+
<div className="req-body">
|
|
348
|
+
<div className="req-body-bar">
|
|
349
|
+
{(['json', 'xml', 'text', 'form', 'multipart'] as const).map((lang) => (
|
|
350
|
+
<button
|
|
351
|
+
key={lang}
|
|
352
|
+
className={`btn btn-sm${value.bodyLang === lang ? ' btn-primary' : ' btn-ghost'}`}
|
|
353
|
+
aria-pressed={value.bodyLang === lang}
|
|
354
|
+
onClick={() => {
|
|
355
|
+
setFormatError(null);
|
|
356
|
+
onChange({ bodyLang: lang });
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
{t(`http.lang.${lang}`)}
|
|
360
|
+
</button>
|
|
361
|
+
))}
|
|
362
|
+
<span className="spacer" />
|
|
363
|
+
{(value.bodyLang === 'json' || value.bodyLang === 'xml') && (
|
|
364
|
+
<button className="btn btn-ghost btn-sm" onClick={format} disabled={value.body.trim() === ''}>{t('http.format')}</button>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
{formatError && <span className="field-error">{t(value.bodyLang === 'xml' ? 'preview.xmlError' : 'preview.jsonError', { message: formatError })}</span>}
|
|
368
|
+
{bodyless && <span className="req-hint">{t('http.bodyIgnored', { method: value.method })}</span>}
|
|
369
|
+
{value.bodyLang === 'form' || value.bodyLang === 'multipart' ? (
|
|
370
|
+
<div className={bodyless ? 'req-body-form disabled' : 'req-body-form'}>
|
|
371
|
+
{value.bodyLang === 'multipart' && <span className="req-hint">{t('http.multipartTextOnly')}</span>}
|
|
372
|
+
<ParamsTab params={value.form ?? []} onChange={(form) => onChange({ form })} removeLabel="http.removeField" max={HTTP_MAX_FORM_FIELDS} variables={variables} />
|
|
373
|
+
</div>
|
|
374
|
+
) : (
|
|
375
|
+
<div className={bodyless ? 'req-body-editor disabled' : 'req-body-editor'}>
|
|
376
|
+
<CodeEditor
|
|
377
|
+
value={value.body}
|
|
378
|
+
lang={value.bodyLang}
|
|
379
|
+
placeholder={t('http.bodyPlaceholder')}
|
|
380
|
+
extensions={highlight}
|
|
381
|
+
onChange={(body) => {
|
|
382
|
+
setFormatError(null);
|
|
383
|
+
onChange({ body });
|
|
384
|
+
}}
|
|
385
|
+
onRun={onSend}
|
|
386
|
+
/>
|
|
387
|
+
</div>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function OptionsTab({ value, onChange }: { value: HttpRequestDraft; onChange(patch: Partial<HttpRequestDraft>): void }) {
|
|
394
|
+
const seconds = value.timeoutMs === undefined ? '' : String(Math.round(value.timeoutMs / 1000));
|
|
395
|
+
return (
|
|
396
|
+
<div className="req-options">
|
|
397
|
+
<label className="field">
|
|
398
|
+
<span>{t('http.timeout')}</span>
|
|
399
|
+
<input
|
|
400
|
+
type="number"
|
|
401
|
+
min={HTTP_TIMEOUT_MIN_MS / 1000}
|
|
402
|
+
max={HTTP_TIMEOUT_MAX_MS / 1000}
|
|
403
|
+
value={seconds}
|
|
404
|
+
placeholder="60"
|
|
405
|
+
onChange={(e) => onChange({ timeoutMs: timeoutFromSeconds(e.target.value) })}
|
|
406
|
+
/>
|
|
407
|
+
</label>
|
|
408
|
+
<label className="req-check">
|
|
409
|
+
<input type="checkbox" checked={value.followRedirects ?? true} onChange={(e) => onChange({ followRedirects: e.target.checked })} />
|
|
410
|
+
<span>{t('http.followRedirects')}</span>
|
|
411
|
+
</label>
|
|
412
|
+
<label className="req-check">
|
|
413
|
+
<input type="checkbox" checked={value.insecure ?? false} onChange={(e) => onChange({ insecure: e.target.checked })} />
|
|
414
|
+
<span>{t('http.insecure')}</span>
|
|
415
|
+
</label>
|
|
416
|
+
{value.insecure && <span className="req-hint tone-warn">{t('http.insecureHint')}</span>}
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { HttpResponseInfo } from '@deckbuilderkit/server-core/protocol';
|
|
2
|
+
import { type ApiError, Badge, CopyButton, ErrorBanner, formatBytes, Icon, JsonTree, PanelBanner, PanelNotice, Spinner, t } from '@deckbuilderkit/ui';
|
|
3
|
+
import { useMemo, useState } from 'react';
|
|
4
|
+
import { CodeView } from './CodeView';
|
|
5
|
+
import { bodyView, formatMs, statusTone } from './http';
|
|
6
|
+
|
|
7
|
+
export interface ResponseViewProps {
|
|
8
|
+
response?: HttpResponseInfo;
|
|
9
|
+
error?: ApiError;
|
|
10
|
+
sending?: boolean;
|
|
11
|
+
onSave?(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Status line, body (JSON tree, text or a binary notice) and headers of one HTTP response. */
|
|
15
|
+
export function ResponseView({ response, error, sending, onSave }: ResponseViewProps) {
|
|
16
|
+
if (sending && !response) return <div className="resp-empty"><Spinner /></div>;
|
|
17
|
+
if (error) return <div className="resp"><ErrorBanner error={error} /></div>;
|
|
18
|
+
if (!response) {
|
|
19
|
+
return <PanelNotice icon={<Icon name="send" size={20} />} title={t('http.noResponse')} message={t('http.noResponseMessage')} />;
|
|
20
|
+
}
|
|
21
|
+
return <ResponseBody response={response} sending={sending} onSave={onSave} />;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ResponseBody({ response, sending, onSave }: { response: HttpResponseInfo; sending?: boolean; onSave?(): void }) {
|
|
25
|
+
const [tab, setTab] = useState<'body' | 'headers'>('body');
|
|
26
|
+
return (
|
|
27
|
+
<div className="resp">
|
|
28
|
+
<div className="resp-status">
|
|
29
|
+
<Badge tone={statusTone(response.status)}>{`${response.status} ${response.statusText}`.trim()}</Badge>
|
|
30
|
+
<span className="resp-meta" title={t('http.timing')}>{formatMs(response.ms)} / {formatMs(response.totalMs)}</span>
|
|
31
|
+
<span className="resp-meta">{formatBytes(response.size)}</span>
|
|
32
|
+
{response.redirected && <span className="resp-meta resp-url" title={response.url}>{response.url}</span>}
|
|
33
|
+
<span className="spacer" />
|
|
34
|
+
{sending && <Spinner small />}
|
|
35
|
+
{onSave && (
|
|
36
|
+
<button className="btn btn-sm" onClick={onSave}><Icon name="download" /> {t('http.save')}</button>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
{response.truncated && <PanelBanner tone="warn" icon="alert">{t('http.truncated', { size: formatBytes(response.size) })}</PanelBanner>}
|
|
40
|
+
<div className="req-tabs" role="tablist">
|
|
41
|
+
{(['body', 'headers'] as const).map((id) => (
|
|
42
|
+
<button key={id} role="tab" aria-selected={tab === id} className={`req-tab${tab === id ? ' active' : ''}`} onClick={() => setTab(id)}>
|
|
43
|
+
{t(`http.tab.${id}`)}
|
|
44
|
+
{id === 'headers' && <span className="req-tab-count">{response.headers.length}</span>}
|
|
45
|
+
</button>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
<div className="resp-panel">
|
|
49
|
+
{tab === 'body' ? <BodyPane response={response} /> : <HeadersPane headers={response.headers} />}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function BodyPane({ response }: { response: HttpResponseInfo }) {
|
|
56
|
+
const view = useMemo(() => bodyView(response), [response]);
|
|
57
|
+
const [raw, setRaw] = useState(false);
|
|
58
|
+
if (view.kind === 'binary') return <PanelNotice icon={<Icon name="cube" size={20} />} title={t('http.binary')} />;
|
|
59
|
+
if (view.kind === 'text') return <div className="resp-body"><CodeView value={view.text} lang={view.lang} lineNumbers /></div>;
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<div className="file-preview-bar">
|
|
63
|
+
<button className="btn btn-ghost btn-sm" onClick={() => setRaw(!raw)} aria-pressed={raw}>{raw ? t('http.tree') : t('http.raw')}</button>
|
|
64
|
+
<CopyButton text={view.pretty} />
|
|
65
|
+
</div>
|
|
66
|
+
<div className="resp-body">
|
|
67
|
+
{raw ? <CodeView value={view.pretty} lang="json" lineNumbers /> : <JsonTree value={view.value} />}
|
|
68
|
+
</div>
|
|
69
|
+
</>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function HeadersPane({ headers }: { headers: Array<[string, string]> }) {
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<div className="file-preview-bar">
|
|
77
|
+
<CopyButton text={headers.map(([name, value]) => `${name}: ${value}`).join('\n')} label={t('http.copyAll')} />
|
|
78
|
+
</div>
|
|
79
|
+
<div className="resp-body">
|
|
80
|
+
<table className="resp-headers">
|
|
81
|
+
<thead><tr><th>{t('http.headerName')}</th><th>{t('http.headerValue')}</th></tr></thead>
|
|
82
|
+
<tbody>
|
|
83
|
+
{headers.map(([name, value], i) => <tr key={i}><td>{name}</td><td>{value}</td></tr>)}
|
|
84
|
+
</tbody>
|
|
85
|
+
</table>
|
|
86
|
+
</div>
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
}
|