pyhttp-app 2.0.1__py3-none-any.whl

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.
pyhttp/static/app.js ADDED
@@ -0,0 +1,2739 @@
1
+ 'use strict';
2
+
3
+ // ------------------------------------------------------------------ helpers
4
+ const $ = (sel, el = document) => el.querySelector(sel);
5
+ const $$ = (sel, el = document) => Array.from(el.querySelectorAll(sel));
6
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
7
+ const fmtMs = (ms) => ms == null ? '' : ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`;
8
+ const fmtBytes = (n) => n == null ? '' : n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(2)} MB`;
9
+ const fmtDate = (ts) => new Date(ts * 1000).toLocaleString();
10
+
11
+ async function api(path, { method = 'GET', body } = {}) {
12
+ const init = { method };
13
+ if (body !== undefined) {
14
+ init.headers = { 'Content-Type': 'application/json' };
15
+ init.body = JSON.stringify(body);
16
+ }
17
+ const res = await fetch('/api' + path, init);
18
+ const ct = res.headers.get('content-type') || '';
19
+ const data = ct.includes('application/json') ? await res.json() : await res.text();
20
+ if (!res.ok) {
21
+ const detail = data && data.detail;
22
+ throw new Error(typeof detail === 'string' ? detail : detail ? JSON.stringify(detail) : res.statusText);
23
+ }
24
+ return data;
25
+ }
26
+
27
+ let toastTimer = null;
28
+ // the URL row's method select wears the color of the method it shows
29
+ function tintMethodSelect() {
30
+ const sel = $('#f-method');
31
+ sel.className = 'method-select m-' + sel.value;
32
+ }
33
+
34
+ function toast(msg, kind = '', action = null) {
35
+ const el = $('#toast');
36
+ el.textContent = msg;
37
+ el.className = 'toast ' + kind;
38
+ if (action) {
39
+ const b = document.createElement('button');
40
+ b.className = 'toast-act';
41
+ b.textContent = action.label;
42
+ b.addEventListener('click', () => { clearTimeout(toastTimer); el.hidden = true; action.onClick(); });
43
+ el.appendChild(b);
44
+ }
45
+ el.hidden = false;
46
+ clearTimeout(toastTimer);
47
+ toastTimer = setTimeout(() => { el.hidden = true; }, action ? 6500 : kind === 'error' ? 6000 : 2800);
48
+ }
49
+
50
+ function bindTabs(tabsEl, panelsEl) {
51
+ $$('button[data-tab]', tabsEl).forEach((btn) => {
52
+ btn.addEventListener('click', () => {
53
+ $$('button[data-tab]', tabsEl).forEach((b) => b.classList.toggle('active', b === btn));
54
+ $$('.tab-panel', panelsEl).forEach((p) => { p.hidden = p.dataset.panel !== btn.dataset.tab; });
55
+ });
56
+ });
57
+ }
58
+ function showTab(tabsEl, name) {
59
+ const btn = $(`button[data-tab="${name}"]`, tabsEl);
60
+ if (btn) btn.click();
61
+ }
62
+
63
+ function prettyBody(resp) {
64
+ if (!resp) return '';
65
+ const text = resp.body_text || '';
66
+ if (resp.is_json) {
67
+ try { return JSON.stringify(JSON.parse(text), null, 2); } catch { /* fall through */ }
68
+ }
69
+ return text + (resp.truncated ? '\n\n… [truncated in viewer; full body saved to file]' : '');
70
+ }
71
+ function formatRequest(req) {
72
+ if (!req) return '';
73
+ const lines = [`${req.method} ${req.url}`];
74
+ (req.headers || []).forEach(([k, v]) => lines.push(`${k}: ${v}`));
75
+ if (req.body) lines.push('', req.body);
76
+ return lines.join('\n');
77
+ }
78
+
79
+ // -------------------------------------------------------------------- modal
80
+ function openModal(title, bodyHtml, footButtons = []) {
81
+ $('#modal-title').textContent = title;
82
+ $('#modal-body').innerHTML = bodyHtml;
83
+ const foot = $('#modal-foot');
84
+ foot.innerHTML = '';
85
+ footButtons.forEach(({ label, cls = '', onClick }) => {
86
+ const b = document.createElement('button');
87
+ b.textContent = label;
88
+ b.className = cls;
89
+ b.addEventListener('click', onClick);
90
+ foot.appendChild(b);
91
+ });
92
+ $('#modal').hidden = false;
93
+ }
94
+ function closeModal() {
95
+ if (typeof envSave !== 'undefined' && envSave.timer) flushEnvSave();
96
+ $('#modal').hidden = true;
97
+ $('#modal-body').innerHTML = ''; // no stale ids or listeners left behind
98
+ $('#modal-foot').innerHTML = '';
99
+ }
100
+
101
+ // --------------------------------------------------------------- kv editor
102
+ function kvRow(r, withEnable, lists = null, secrets = false) {
103
+ const row = document.createElement('div');
104
+ row.className = 'kv-row' + (secrets ? ' with-secret' : '') + (r.secret ? ' secret' : '');
105
+ row.innerHTML =
106
+ (withEnable ? `<input type="checkbox" class="kv-on" ${r.enabled !== false ? 'checked' : ''}>` : '') +
107
+ `<input class="kv-key mono" placeholder="Key" value="${esc(r.key)}" spellcheck="false" autocomplete="off" ${lists ? `list="${lists.key}"` : ''}>` +
108
+ `<input class="kv-val mono" placeholder="Value" value="${esc(r.value)}" spellcheck="false" autocomplete="off" ${lists ? `list="${lists.value}"` : ''} ${r.secret ? 'type="password"' : ''}>` +
109
+ (secrets ? `<button class="kv-secret" title="${r.secret ? 'Secret: masked here and in saved history and run files. Click to make it a normal value' : 'Mark as secret (masked here and in saved history and run files)'}">${r.secret ? '🔒' : '🔓'}</button>` : '') +
110
+ `<button class="kv-del" title="Remove">✕</button>`;
111
+ $('.kv-del', row).addEventListener('click', () => row.remove());
112
+ if (secrets) {
113
+ const val = $('.kv-val', row);
114
+ $('.kv-secret', row).addEventListener('click', (e) => {
115
+ const on = !row.classList.contains('secret');
116
+ row.classList.toggle('secret', on);
117
+ val.type = on ? 'password' : 'text';
118
+ e.currentTarget.textContent = on ? '🔒' : '🔓';
119
+ e.currentTarget.title = on ? 'Secret: masked here and in saved history and run files. Click to make it a normal value' : 'Mark as secret (masked here and in saved history and run files)';
120
+ });
121
+ // a secret shows while it is being edited
122
+ val.addEventListener('focus', () => { if (row.classList.contains('secret')) val.type = 'text'; });
123
+ val.addEventListener('blur', () => { if (row.classList.contains('secret')) val.type = 'password'; });
124
+ }
125
+ return row;
126
+ }
127
+ // Rows <-> text for bulk edit. One "key: value" per line; "//" disables a line.
128
+ function kvToText(rows, withEnable) {
129
+ return (rows || []).map((r) => (withEnable && r.enabled === false ? '// ' : '') + `${r.key}: ${r.value}`).join('\n');
130
+ }
131
+ function parseBulk(text, withEnable) {
132
+ const rows = [];
133
+ const push = (part, enabled) => {
134
+ const ci = part.indexOf(':');
135
+ const ei = part.indexOf('=');
136
+ const i = ei !== -1 && (ci === -1 || ei < ci) ? ei : ci;
137
+ const key = (i === -1 ? part : part.slice(0, i)).trim();
138
+ const value = i === -1 ? '' : part.slice(i + 1).trim();
139
+ if (key || value) rows.push({ key, value, enabled: withEnable ? enabled : true });
140
+ };
141
+ text.split(/\r?\n/).forEach((line) => {
142
+ let l = line.trim();
143
+ if (!l) return;
144
+ let enabled = true;
145
+ if (l.startsWith('//')) { enabled = false; l = l.slice(2).trim(); }
146
+ if (l.startsWith('?')) l = l.slice(1);
147
+ // a pasted query string such as a=1&b=2 becomes several rows
148
+ if (!l.includes(':') && l.includes('=') && l.includes('&')) l.split('&').forEach((part) => push(part, enabled));
149
+ else push(l, enabled);
150
+ });
151
+ return rows;
152
+ }
153
+ function renderKv(container, rows, withEnable = true) {
154
+ container.innerHTML = '';
155
+ container.classList.toggle('no-enable', !withEnable);
156
+ container.dataset.withEnable = withEnable ? '1' : '0';
157
+ const bulk = container.dataset.mode === 'bulk';
158
+ const bar = document.createElement('div');
159
+ bar.className = 'kv-bar';
160
+ const toggle = document.createElement('button');
161
+ toggle.className = 'link small kv-mode';
162
+ toggle.textContent = bulk ? 'Key-value edit' : 'Bulk edit';
163
+ toggle.title = bulk ? 'Back to rows' : 'Edit all rows as text, one "key: value" per line';
164
+ toggle.addEventListener('click', () => {
165
+ const current = readKv(container);
166
+ container.dataset.mode = bulk ? 'rows' : 'bulk';
167
+ renderKv(container, current, withEnable);
168
+ const ta = $('.kv-bulk', container);
169
+ if (ta) ta.focus();
170
+ });
171
+ const secrets = container.dataset.secrets === '1';
172
+ if (secrets) container._secretKeys = new Set((rows || []).filter((r) => r.secret).map((r) => r.key));
173
+ if (bulk) {
174
+ const ta = document.createElement('textarea');
175
+ ta.className = 'kv-bulk mono';
176
+ ta.spellcheck = false;
177
+ ta.rows = Math.min(16, Math.max(5, (rows || []).length + 2));
178
+ ta.placeholder = withEnable ? 'Content-Type: application/json\n// X-Disabled: lines starting with // are off' : 'base_url: https://api.example.com';
179
+ ta.value = kvToText(rows, withEnable);
180
+ container.appendChild(ta);
181
+ const hint = document.createElement('span');
182
+ hint.className = 'muted small';
183
+ hint.textContent = withEnable ? 'One key: value per line. Start a line with // to disable it.' : 'One key: value per line.';
184
+ bar.appendChild(hint);
185
+ } else {
186
+ const lists = container.id === 'kv-headers' || container.id === 'fs-headers' ? { key: 'header-names', value: 'header-values' } : null;
187
+ const list = document.createElement('div');
188
+ list.className = 'kv-rows';
189
+ (rows || []).forEach((r) => list.appendChild(kvRow(r, withEnable, lists, secrets)));
190
+ // select all / unselect all: one checkbox above the enable column
191
+ let syncAll = () => {};
192
+ if (withEnable) {
193
+ const head = document.createElement('label');
194
+ head.className = 'kv-all muted small';
195
+ head.innerHTML = '<input type="checkbox" class="kv-all-on"> <span></span>';
196
+ const all = $('.kv-all-on', head);
197
+ syncAll = () => {
198
+ const boxes = $$('.kv-on', list);
199
+ const on = boxes.filter((b) => b.checked).length;
200
+ all.checked = boxes.length > 0 && on === boxes.length;
201
+ all.indeterminate = on > 0 && on < boxes.length;
202
+ all.disabled = !boxes.length;
203
+ $('span', head).textContent = all.checked ? 'Unselect all' : 'Select all';
204
+ head.title = all.checked ? 'Turn every row off' : 'Turn every row on';
205
+ };
206
+ all.addEventListener('change', (e) => {
207
+ e.stopPropagation();
208
+ const on = all.checked;
209
+ $$('.kv-on', list).forEach((b) => { b.checked = on; });
210
+ syncAll();
211
+ // let the editor save and refresh its counts as if a row was toggled
212
+ container.dispatchEvent(new Event('input', { bubbles: true }));
213
+ container.dispatchEvent(new Event('change', { bubbles: true }));
214
+ });
215
+ list.addEventListener('change', (e) => { if (e.target.classList.contains('kv-on')) syncAll(); });
216
+ list.addEventListener('click', (e) => { if (e.target.closest('.kv-del')) setTimeout(syncAll, 0); });
217
+ container.appendChild(head);
218
+ }
219
+ container.appendChild(list);
220
+ syncAll();
221
+ const add = document.createElement('button');
222
+ add.className = 'link';
223
+ add.textContent = '+ Add row';
224
+ add.addEventListener('click', () => {
225
+ const row = kvRow({ key: '', value: '', enabled: true }, withEnable, lists, secrets);
226
+ list.appendChild(row);
227
+ syncAll();
228
+ $('.kv-key', row).focus();
229
+ });
230
+ bar.appendChild(add);
231
+ }
232
+ const spacer = document.createElement('span');
233
+ spacer.className = 'grow';
234
+ bar.appendChild(spacer);
235
+ bar.appendChild(toggle);
236
+ container.appendChild(bar);
237
+ }
238
+ function readKv(container) {
239
+ const ta = $('.kv-bulk', container);
240
+ const secrets = container.dataset.secrets === '1';
241
+ if (ta) {
242
+ // bulk text has no secret flag: keys that were secret stay secret
243
+ const keys = container._secretKeys || new Set();
244
+ return parseBulk(ta.value, container.dataset.withEnable !== '0').map((r) => (secrets && keys.has(r.key) ? { ...r, secret: true } : r));
245
+ }
246
+ return $$('.kv-row', container).map((row) => ({
247
+ enabled: $('.kv-on', row) ? $('.kv-on', row).checked : true,
248
+ key: $('.kv-key', row).value,
249
+ value: $('.kv-val', row).value,
250
+ ...(secrets && row.classList.contains('secret') ? { secret: true } : {}),
251
+ })).filter((r) => r.key || r.value);
252
+ }
253
+
254
+ // -------------------------------------------------------------------- state
255
+ const state = {
256
+ requests: [],
257
+ tags: [],
258
+ q: '',
259
+ tag: '',
260
+ selected: [], // [{id, name, method}] in selection order
261
+ current: null, // request being edited (null id => unsaved)
262
+ activeRunId: null,
263
+ sending: null, // {kind: "single"|"run", key, id|runId} while a Send is in flight
264
+ pollTimer: null,
265
+ bodies: [], // named bodies of the request in the editor
266
+ activeBodyId: null,
267
+ bodyQ: '',
268
+ responses: {}, // responses per request, one per body
269
+ folders: [],
270
+ expanded: new Set(), // open folder ids (remembered)
271
+ treeOrder: [], // filtered requests in sidebar order
272
+ drag: null,
273
+ envs: [],
274
+ activeEnvId: null,
275
+ };
276
+ const isSelected = (id) => state.selected.some((s) => s.id === id);
277
+
278
+ // --------------------------------------------------------------- collection
279
+ async function loadRequests() {
280
+ const qs = new URLSearchParams({ q: state.q, tag: state.tag });
281
+ const [requests, tags, folders] = await Promise.all([
282
+ api('/requests?' + qs.toString()), api('/tags'), api('/folders'),
283
+ ]);
284
+ state.requests = requests.filter((r) => !pendingDeletes.has(r.id));
285
+ state.tags = tags;
286
+ state.folders = folders;
287
+ renderTags();
288
+ renderList();
289
+ }
290
+
291
+ function renderTags() {
292
+ const box = $('#tag-filter');
293
+ box.innerHTML = '';
294
+ state.tags.forEach(({ tag, count }) => {
295
+ const chip = document.createElement('span');
296
+ chip.className = 'chip' + (state.tag === tag ? ' active' : '');
297
+ chip.innerHTML = `${esc(tag)}<span class="n">${count}</span>`;
298
+ chip.addEventListener('click', () => {
299
+ state.tag = state.tag === tag ? '' : tag;
300
+ loadRequests();
301
+ });
302
+ box.appendChild(chip);
303
+ });
304
+ }
305
+
306
+ // ------------------------------------------------- keyboard list navigation
307
+ // ArrowUp/Down move a focus ring over the visible requests; Enter opens, Space
308
+ // ticks for a run, Delete deletes (undoable). Esc clears the ring.
309
+ function kbMove(step) {
310
+ const rows = $$('#req-list .req-item');
311
+ if (!rows.length) return;
312
+ let i = rows.findIndex((row) => row.dataset.rid === state.kbId);
313
+ if (i === -1) i = rows.findIndex((row) => row.classList.contains('active'));
314
+ i = Math.min(rows.length - 1, Math.max(0, i + step));
315
+ state.kbId = rows[i].dataset.rid;
316
+ rows.forEach((row) => row.classList.toggle('kb-focus', row.dataset.rid === state.kbId));
317
+ rows[i].scrollIntoView({ block: 'nearest' });
318
+ }
319
+
320
+ function toggleSelect(r) {
321
+ if (isSelected(r.id)) state.selected = state.selected.filter((s) => s.id !== r.id);
322
+ else state.selected.push(selEntry(r));
323
+ renderList();
324
+ }
325
+ function updateSelectionBar() {
326
+ const n = state.selected.length;
327
+ $('#selected-count').textContent = `${n} selected`;
328
+ $('#btn-run').disabled = n === 0;
329
+ $('#btn-run').textContent = n ? `▶ Run selected (${n})` : '▶ Run selected';
330
+ $('#btn-run-now').disabled = n === 0;
331
+ $('#btn-clear-sel').hidden = n === 0;
332
+ const shown = state.requests;
333
+ $('#select-all').checked = shown.length > 0 && shown.every((r) => isSelected(r.id));
334
+ }
335
+
336
+ // ------------------------------------------------------------------- editor
337
+ const blankRequest = () => ({
338
+ id: null, name: '', description: '', tags: [], method: 'GET', url: '',
339
+ params: [], headers: [], body_type: 'none', bodies: [], timeout: 30, verify_ssl: true,
340
+ });
341
+ // How many times a request is sent in a run: once per checked body (at least once).
342
+ function sendCount(r) {
343
+ if (!r || r.body_type === 'none') return 1;
344
+ return Math.max(1, (r.bodies || []).filter((b) => b.enabled).length);
345
+ }
346
+ const selEntry = (r) => ({ id: r.id, name: r.name, method: r.method, n: sendCount(r) });
347
+
348
+ async function openRequest(id) {
349
+ if (id === 'draft') return;
350
+ if (state.current && state.current.id === id) return;
351
+ await saveNow();
352
+ state.current = await api('/requests/' + id);
353
+ tabsAdd(state.current);
354
+ renderEditor();
355
+ renderList();
356
+ }
357
+ async function newRequest(folderId = null) {
358
+ // saveNow() reads the editor synchronously, so the blank form can show at once
359
+ const pending = autosave.inFlight ? saveNow() : (saveNow(), null);
360
+ if (pending) await pending;
361
+ state.current = { ...blankRequest(), folder_id: folderId || null };
362
+ if (folderId) expandTo(folderId);
363
+ tabsAdd(state.current);
364
+ renderEditor();
365
+ renderList();
366
+ $('#f-url').focus();
367
+ }
368
+ function renderEditor() {
369
+ const r = state.current;
370
+ $('#editor-empty').hidden = !!r;
371
+ $('#editor-form').hidden = !r;
372
+ renderReqTabs();
373
+ if (!r) { $('#response').hidden = true; return; }
374
+ $('#f-name').value = r.name;
375
+ $('#f-tags').value = r.tags.join(', ');
376
+ $('#f-description').value = r.description;
377
+ resetDescriptionPreview();
378
+ $('#f-method').value = r.method;
379
+ tintMethodSelect();
380
+ $('#f-url').value = r.url;
381
+ renderKv($('#kv-params'), r.params);
382
+ renderKv($('#kv-headers'), r.headers);
383
+ $('#f-body-type').value = r.body_type;
384
+ state.bodies = (r.bodies || []).map((b) => ({ ...b }));
385
+ const remembered = r.options && r.options.active_body;
386
+ const fallback = state.bodies.find((b) => b.enabled) || state.bodies[0] || null;
387
+ state.activeBodyId = state.bodies.some((b) => b.id === remembered) ? remembered : (fallback ? fallback.id : null);
388
+ state.bodyQ = '';
389
+ $('#body-search').value = '';
390
+ renderBodies();
391
+ $('#f-timeout').value = r.timeout;
392
+ inheritedInfo = null;
393
+ renderAuthPanel(r);
394
+ renderTestsPanel(r);
395
+ renderRequestSettings(r);
396
+ renderPathVars();
397
+ renderInheritedHeaders();
398
+ updateTabCounts();
399
+ refreshInherited();
400
+ setSaveState(r.id ? 'saved' : '', r.id ? 'Saved' : 'Draft (saves when you edit)');
401
+ renderMetaTags();
402
+ renderCrumb();
403
+ renderResponseArea();
404
+ }
405
+ function readEditor() {
406
+ flushBody();
407
+ return {
408
+ name: $('#f-name').value.trim() || 'Untitled',
409
+ description: $('#f-description').value,
410
+ tags: $('#f-tags').value.split(',').map((t) => t.trim()).filter(Boolean),
411
+ method: $('#f-method').value,
412
+ url: $('#f-url').value.trim(),
413
+ params: readKv($('#kv-params')),
414
+ headers: readKv($('#kv-headers')),
415
+ body_type: $('#f-body-type').value,
416
+ bodies: state.bodies.map((b) => ({ ...b })),
417
+ timeout: Number($('#f-timeout').value) || 30,
418
+ auth: readAuth(),
419
+ ...readTests(),
420
+ options: readRequestOptions(),
421
+ };
422
+ }
423
+
424
+ // ------------------------------------------------------------ named bodies
425
+ const activeBody = () => state.bodies.find((b) => b.id === state.activeBodyId) || null;
426
+ const newBodyId = () => 'b' + Math.random().toString(36).slice(2, 10);
427
+
428
+ function flushBody() {
429
+ const b = activeBody();
430
+ if (!b) return;
431
+ b.name = $('#body-name').value;
432
+ b.body = readBodyEditor();
433
+ }
434
+ function bodyMatches(b, q) {
435
+ if (!q) return true;
436
+ const hay = `${b.name}\n${b.body}`.toLowerCase();
437
+ return q.toLowerCase().split(/\s+/).filter(Boolean).every((t) => hay.includes(t));
438
+ }
439
+ function renderBodies() {
440
+ const ul = $('#body-list');
441
+ const shown = state.bodies.filter((b) => bodyMatches(b, state.bodyQ));
442
+ ul.innerHTML = '';
443
+ if (!state.bodies.length) ul.innerHTML = '<li class="none">No bodies yet.</li>';
444
+ else if (!shown.length) ul.innerHTML = '<li class="none">No bodies match.</li>';
445
+ shown.forEach((b) => {
446
+ const li = document.createElement('li');
447
+ li.className = 'body-item' + (b.id === state.activeBodyId ? ' active' : '') + (b.enabled ? '' : ' off');
448
+ const preview = (b.body || '').replace(/\s+/g, ' ').slice(0, 80) || 'empty';
449
+ li.innerHTML =
450
+ `<input type="checkbox" ${b.enabled ? 'checked' : ''} title="Send this body">` +
451
+ `<div style="min-width:0"><div class="bname">${esc(b.name || 'Unnamed')}</div><div class="bprev">${esc(preview)}</div></div>`;
452
+ $('input', li).addEventListener('click', (e) => {
453
+ e.stopPropagation();
454
+ b.enabled = e.target.checked;
455
+ renderBodies();
456
+ scheduleSave(); // checked bodies are remembered with the request, across tabs and restarts
457
+ });
458
+ li.addEventListener('click', () => selectBody(b.id));
459
+ ul.appendChild(li);
460
+ });
461
+ const b = activeBody();
462
+ $('#body-editor').hidden = !b;
463
+ $('#body-editor-empty').hidden = !!b;
464
+ if (b) {
465
+ if ($('#body-name').value !== b.name) $('#body-name').value = b.name;
466
+ showBodyEditor(b);
467
+ }
468
+ updateBodyHints();
469
+ updateTabCounts();
470
+ if (typeof refreshTestBodyScopes === 'function') refreshTestBodyScopes(); // tests can be scoped to a body
471
+ }
472
+ function updateBodyHints() {
473
+ const total = state.bodies.length;
474
+ const on = state.bodies.filter((b) => b.enabled).length;
475
+ $('#bodies-count').textContent = total ? `${on} of ${total} checked` : '';
476
+ const type = $('#f-body-type').value;
477
+ let hint = '';
478
+ if (type === 'none' && total) hint = 'Body type is “No body”, so bodies are not sent.';
479
+ else if (type !== 'none' && on > 1) hint = `Send will hit this request ${on} times, once per checked body, and save each output.`;
480
+ else if (type !== 'none' && on === 1) hint = 'Send uses the one checked body.';
481
+ else if (type !== 'none' && total) hint = 'No body is checked, so the request is sent without a body.';
482
+ $('#bodies-hint').textContent = hint;
483
+ renderSendButton();
484
+ }
485
+ // Send turns into Cancel while this request's call (or multi-body run) is in flight.
486
+ // What happened to the request this one depends on (token fetched, still fresh, refreshed and retried, failed)
487
+ function renderDependencyBadge(dep) {
488
+ const el = $('#r-dep');
489
+ if (!el) return;
490
+ el.hidden = !dep;
491
+ if (!dep) return;
492
+ const name = dep.name || 'dependency';
493
+ let text = '', title = '', cls = 'dep-badge';
494
+ if (dep.state === 'shared') { text = `⛓ ${name}: shared`; title = `“${name}” was sent once for all the bodies sent together, so it was not sent again for this one.`; }
495
+ else if (dep.state === 'fresh') { text = `⛓ ${name}: reused`; title = `“${name}” was sent ${fmtAge(dep.age)} ago and is still valid for ${fmtAge(Math.max(0, dep.ttl - dep.age))}, so it was not sent again.`; }
496
+ else if (dep.state === 'ran' && dep.ok) { text = `⛓ ${name}: sent first${dep.retried ? ', then retried' : ''}`; title = `“${name}” was sent before this request (${dep.status_code ?? 'OK'}, ${fmtMs(dep.duration_ms)})${dep.retried ? ' because the previous response was 401/403; this request was then sent again' : ''}.`; }
497
+ else if (dep.state === 'ran') { text = `⛓ ${name}: failed`; cls += ' fail'; title = `“${name}” failed (${dep.error || 'HTTP ' + dep.status_code}).`; }
498
+ else if (dep.state === 'missing') { text = '⛓ dependency missing'; cls += ' fail'; title = 'The request this one depends on was deleted. Pick another in the Settings tab.'; }
499
+ else if (dep.state === 'loop') { text = `⛓ ${name}: skipped`; title = 'Skipped: the dependencies form a loop.'; }
500
+ el.textContent = text; el.title = title; el.className = cls;
501
+ }
502
+ function renderSendButton() {
503
+ const btn = $('#btn-send');
504
+ const type = $('#f-body-type').value;
505
+ const on = state.bodies.filter((b) => b.enabled).length;
506
+ const n = type === 'none' ? 1 : on;
507
+ const sending = state.sending && state.sending.key === responseKey();
508
+ btn.classList.toggle('cancel', !!sending);
509
+ btn.disabled = false;
510
+ const par = !!appSettings.parallel;
511
+ btn.title = sending ? 'Stop the call(s) in flight' : n > 1 ? `Send the ${n} checked bodies ${par ? 'in parallel (at most ' + (appSettings.concurrency || 8) + ' at once)' : 'one after another'}. Use ▾ to change. Ctrl+Enter` : 'Ctrl+Enter';
512
+ btn.textContent = sending ? (state.sending.kind === 'run' ? `Cancel (${state.sending.done || 0}/${state.sending.total})` : 'Cancel') : (n > 1 ? `Send ×${n}${par ? ' ∥' : ''}` : 'Send');
513
+ const mode = $('#btn-send-mode');
514
+ mode.hidden = sending || n < 2;
515
+ mode.title = `Sending ${par ? 'in parallel' : 'in order'}. Click to change how the checked bodies are sent.`;
516
+ renderSendingUi();
517
+ }
518
+
519
+ // While a call is in flight: a sliding bar under the URL, a live strip with the elapsed time on
520
+ // top of the response (the previous response stays usable, slightly dimmed), a shimmer placeholder
521
+ // on the first send, and a spinner on the request's tab and sidebar entry. Nothing is blocked.
522
+ const sendingUi = { timer: null };
523
+ function fmtElapsed(ms) {
524
+ if (ms < 10000) return (ms / 1000).toFixed(1) + ' s';
525
+ if (ms < 60000) return Math.floor(ms / 1000) + ' s';
526
+ const sec = Math.floor(ms / 1000);
527
+ return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, '0')}s`;
528
+ }
529
+ function sendingLabel(s) {
530
+ if (s.kind === 'run') return `Sending ${s.total} bodies${s.done ? ` · ${s.done} of ${s.total} done` : ''}`;
531
+ let target = $('#f-url').value.trim();
532
+ if (!target.includes('{{')) { try { const u = new URL(target); target = u.host + u.pathname; } catch { /* keep as typed */ } }
533
+ return `Sending ${$('#f-method').value} ${target.length > 60 ? target.slice(0, 60) + '…' : target}`;
534
+ }
535
+ function tickSendingUi() {
536
+ const s = state.sending;
537
+ $$('.req-tab').forEach((el) => el.classList.toggle('sending', !!s && el.dataset.key === s.key));
538
+ $$('.req-item').forEach((el) => el.classList.toggle('sending', !!s && el.dataset.rid === s.key));
539
+ const live = $('#resp-live');
540
+ if (!s || !live) return;
541
+ $('.rl-time', live).textContent = fmtElapsed(performance.now() - s.started);
542
+ $('.rl-text', live).textContent = sendingLabel(s);
543
+ }
544
+ function renderSendingUi() {
545
+ const s = state.sending;
546
+ if (s && !s.started) s.started = performance.now();
547
+ const here = !!(s && s.key === responseKey());
548
+ const resp = $('#response');
549
+ $('.url-row').classList.toggle('sending', here);
550
+ let live = $('#resp-live');
551
+ if (here) {
552
+ if (resp.hidden) { resp.hidden = false; resp.classList.add('first-send'); }
553
+ resp.classList.add('is-sending');
554
+ if (!live) {
555
+ live = document.createElement('div');
556
+ live.id = 'resp-live';
557
+ live.className = 'resp-live';
558
+ live.setAttribute('role', 'status');
559
+ live.innerHTML = '<span class="rl-dot" aria-hidden="true"></span><span class="rl-text"></span><span class="grow"></span><span class="rl-time mono"></span>' +
560
+ '<div class="rl-skeleton" aria-hidden="true"><i style="width:38%"></i><i style="width:72%"></i><i style="width:56%"></i><i style="width:64%"></i><i style="width:30%"></i></div>';
561
+ const at = $('#resp-bodies');
562
+ at.parentNode.insertBefore(live, at);
563
+ }
564
+ } else {
565
+ resp.classList.remove('is-sending');
566
+ if (live) live.remove();
567
+ if (resp.classList.contains('first-send')) {
568
+ resp.classList.remove('first-send');
569
+ const rs = responsesFor();
570
+ if (!rs || !Object.keys(rs.byKey).length) resp.hidden = true;
571
+ }
572
+ }
573
+ tickSendingUi();
574
+ if (s && !sendingUi.timer) sendingUi.timer = setInterval(tickSendingUi, 100);
575
+ if (!s && sendingUi.timer) { clearInterval(sendingUi.timer); sendingUi.timer = null; tickSendingUi(); }
576
+ }
577
+ function openSendModeMenu(anchor) {
578
+ const par = !!appSettings.parallel;
579
+ const pick = async (parallel) => {
580
+ await saveSettings({ parallel });
581
+ renderSendButton();
582
+ toast(parallel ? `Bodies are sent in parallel, at most ${appSettings.concurrency || 8} at once (Settings → Requests)` : 'Bodies are sent one after another', 'ok');
583
+ };
584
+ openMenu('send-mode', anchor, null, [
585
+ { act: 'seq', icon: par ? '' : '✓', label: 'Send in order, one after another', run: () => pick(false) },
586
+ { act: 'par', icon: par ? '✓' : '', label: `Send in parallel, at most ${appSettings.concurrency || 8} at once`, run: () => pick(true) },
587
+ ]);
588
+ }
589
+ async function cancelSending() {
590
+ const s = state.sending;
591
+ if (!s) return;
592
+ if (streamAbort) { streamAbort.abort(); toast('Stream stopped'); return; }
593
+ try {
594
+ if (s.kind === 'run') await api(`/runs/${s.runId}/cancel`, { method: 'POST' });
595
+ else if (s.id) await api(`/send/${s.id}/cancel`, { method: 'POST' });
596
+ toast('Cancelling…');
597
+ } catch (e) { toast(e.message, 'error'); }
598
+ }
599
+
600
+ // --------------------------------------------------- streaming send (SSE, chunked)
601
+ // POST /api/send-stream and show the body as it arrives in a live pane; the final
602
+ // "done" event carries the same result object /api/send returns, rendered as usual.
603
+ // Stopping (the Send button turned Cancel) closes the connection and keeps what arrived.
604
+ let streamAbort = null;
605
+ async function sendStreaming(payload) {
606
+ const started = performance.now();
607
+ streamAbort = new AbortController();
608
+ const box = $('#response');
609
+ box.hidden = false;
610
+ box.classList.add('streaming');
611
+ box.classList.remove('first-send');
612
+ let live = $('#r-stream-live');
613
+ if (!live) {
614
+ live = document.createElement('pre');
615
+ live.id = 'r-stream-live';
616
+ box.appendChild(live);
617
+ }
618
+ live.textContent = '';
619
+ let meta = null;
620
+ let received = '';
621
+ let result = null;
622
+ try {
623
+ const res = await fetch('/api/send-stream', {
624
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
625
+ body: JSON.stringify(payload), signal: streamAbort.signal,
626
+ });
627
+ if (!res.ok) throw new Error(res.statusText || 'HTTP ' + res.status);
628
+ const reader = res.body.getReader();
629
+ const dec = new TextDecoder();
630
+ let buf = '';
631
+ for (;;) {
632
+ const { value, done } = await reader.read();
633
+ if (done) break;
634
+ buf += dec.decode(value, { stream: true });
635
+ let nl;
636
+ while ((nl = buf.indexOf('\n')) >= 0) {
637
+ const line = buf.slice(0, nl);
638
+ buf = buf.slice(nl + 1);
639
+ if (!line.trim()) continue;
640
+ const ev = JSON.parse(line);
641
+ if (ev.type === 'status') {
642
+ meta = ev;
643
+ const c = ev.status_code || 0;
644
+ $('#r-status').className = 'status ' + (c && c < 400 ? 'ok' : c < 500 ? 'warn' : 'err');
645
+ $('#r-status').textContent = `${c} ${ev.reason || ''}`.trim();
646
+ $('#r-time').textContent = 'streaming…';
647
+ } else if (ev.type === 'chunk') {
648
+ received += ev.text;
649
+ const stick = live.scrollTop + live.clientHeight >= live.scrollHeight - 12;
650
+ live.append(ev.text);
651
+ if (stick) live.scrollTop = live.scrollHeight;
652
+ } else if (ev.type === 'done') {
653
+ result = ev.result;
654
+ }
655
+ }
656
+ }
657
+ } catch (e) {
658
+ if (e.name !== 'AbortError') toast('Send failed: ' + e.message, 'error');
659
+ } finally {
660
+ streamAbort = null;
661
+ box.classList.remove('streaming');
662
+ const el = $('#r-stream-live');
663
+ if (el) el.remove();
664
+ }
665
+ if (result || !meta) return result; // finished normally, or nothing arrived at all
666
+ // stopped mid-stream: keep what arrived as the response
667
+ return {
668
+ status: 'ok', status_code: meta.status_code, reason: meta.reason,
669
+ duration_ms: performance.now() - started, error: null,
670
+ request: { method: payload.method, url: payload.url, params: [], headers: [], body: null },
671
+ response: {
672
+ headers: meta.headers || [], content_type: meta.content_type || '', size: received.length,
673
+ body_text: received, truncated: false, is_json: false, is_binary: false, body_base64: null,
674
+ http_version: meta.http_version || '', redirects: [], hops: [], cookies: [],
675
+ },
676
+ tests: [], tests_failed: 0, extracted: [], pre_vars: [], scripts: {}, stopped: true,
677
+ };
678
+ }
679
+ function sendOrCancel() {
680
+ if (state.sending && state.sending.key === responseKey()) cancelSending(); else sendCurrent();
681
+ }
682
+ function selectBody(id) {
683
+ flushBody();
684
+ if (state.activeBodyId !== id) scheduleSave(); // the selected body is remembered with the request
685
+ state.activeBodyId = id;
686
+ renderBodies();
687
+ // show the response that belongs to this body, if there is one
688
+ const rs = responsesFor();
689
+ const key = rs && (rs.byKey[id] ? id : Object.keys(rs.byKey).find((k) => bodyOfKey(k) === id));
690
+ if (key && bodyOfKey(rs.selected || '') !== id) { rs.selected = key; renderResponseArea(); }
691
+ else if (rs && rs.selected && rs.byKey[rs.selected] && !rs.byKey[rs.selected].pending && !rs.byKey[rs.selected].notSent) renderResponse(rs.byKey[rs.selected], !$('#resp-bodies').hidden);
692
+ }
693
+ function addBody(from) {
694
+ flushBody();
695
+ const b = {
696
+ id: newBodyId(),
697
+ name: from ? `${from.name} (copy)` : `Body ${state.bodies.length + 1}`,
698
+ body: from ? from.body : '',
699
+ enabled: true,
700
+ };
701
+ state.bodies.push(b);
702
+ state.activeBodyId = b.id;
703
+ if ($('#f-body-type').value === 'none') $('#f-body-type').value = 'json';
704
+ if (!bodyMatches(b, state.bodyQ)) { state.bodyQ = ''; $('#body-search').value = ''; }
705
+ renderBodies();
706
+ scheduleSave();
707
+ $('#body-name').focus();
708
+ $('#body-name').select();
709
+ }
710
+ async function deleteBody() {
711
+ const b = activeBody();
712
+ if (!b) return;
713
+ const reqId = state.current && state.current.id;
714
+ const i = state.bodies.indexOf(b);
715
+ state.bodies.splice(i, 1);
716
+ const next = state.bodies[Math.min(i, state.bodies.length - 1)];
717
+ state.activeBodyId = next ? next.id : null;
718
+ renderBodies();
719
+ renderResponseArea();
720
+ scheduleSave();
721
+ toast(`Deleted body “${b.name || 'Unnamed'}”`, '', { label: 'Undo', onClick: () => {
722
+ if (!state.current || state.current.id !== reqId) { toast('That body belongs to another request', 'error'); return; }
723
+ state.bodies.splice(Math.min(i, state.bodies.length), 0, b);
724
+ state.activeBodyId = b.id;
725
+ renderBodies();
726
+ renderResponseArea();
727
+ scheduleSave();
728
+ } });
729
+ }
730
+ function setAllBodies(on) {
731
+ state.bodies.filter((b) => bodyMatches(b, state.bodyQ)).forEach((b) => { b.enabled = on; });
732
+ renderBodies();
733
+ scheduleSave();
734
+ }
735
+
736
+ // ----------------------------------------------------------------- autosave
737
+ // Every edit schedules a save shortly after typing stops. Saves never touch
738
+ // the editor's fields, so typing during a save is never overwritten.
739
+ const autosave = { timer: null, inFlight: null, dirty: false, retry: null };
740
+ const AUTOSAVE_MS = 500;
741
+
742
+ function setSaveState(kind, text) {
743
+ const el = $('#save-state');
744
+ el.className = 'save-state ' + kind;
745
+ el.textContent = text;
746
+ if (typeof renderReqTabs === 'function') renderReqTabs();
747
+ }
748
+ function scheduleSave() {
749
+ if (!state.current) return;
750
+ autosave.dirty = true;
751
+ setSaveState('saving', 'Editing…');
752
+ clearTimeout(autosave.timer);
753
+ autosave.timer = setTimeout(saveNow, AUTOSAVE_MS);
754
+ }
755
+ async function saveNow() {
756
+ clearTimeout(autosave.timer);
757
+ clearTimeout(autosave.retry);
758
+ autosave.timer = null;
759
+ while (autosave.inFlight) await autosave.inFlight;
760
+ if (!autosave.dirty || !state.current) return;
761
+ autosave.dirty = false;
762
+ const target = state.current;
763
+ const data = readEditor();
764
+ setSaveState('saving', 'Saving…');
765
+ autosave.inFlight = (async () => {
766
+ try {
767
+ const saved = target.id
768
+ ? await api('/requests/' + target.id, { method: 'PUT', body: data })
769
+ : await api('/requests', { method: 'POST', body: { ...data, folder_id: target.folder_id || null } });
770
+ const wasDraft = !target.id;
771
+ if (wasDraft) moveResponses('draft', saved.id);
772
+ Object.assign(target, { id: saved.id, folder_id: saved.folder_id, name: saved.name, method: saved.method, tags: saved.tags,
773
+ body_type: saved.body_type, bodies: saved.bodies, auth: saved.auth, tests: saved.tests, extract: saved.extract,
774
+ pre_vars: saved.pre_vars, options: saved.options });
775
+ if (wasDraft) tabsRename('draft', saved.id);
776
+ state.selected = state.selected.map((x) => x.id === saved.id ? selEntry(saved) : x);
777
+ if (state.current === target) {
778
+ if (!autosave.dirty) setSaveState('saved', 'Saved');
779
+ }
780
+ loadRequests();
781
+ } catch (e) {
782
+ autosave.dirty = true;
783
+ setSaveState('error', 'Not saved, retrying…');
784
+ toast('Autosave failed: ' + e.message, 'error');
785
+ autosave.retry = setTimeout(saveNow, 3000);
786
+ } finally {
787
+ autosave.inFlight = null;
788
+ }
789
+ })();
790
+ await autosave.inFlight;
791
+ }
792
+ // Last-chance save when the window closes with pending edits.
793
+ function saveOnExit() {
794
+ if (!autosave.dirty || !state.current) return;
795
+ const data = readEditor();
796
+ const id = state.current.id;
797
+ fetch('/api/requests' + (id ? '/' + id : ''), {
798
+ method: id ? 'PUT' : 'POST', keepalive: true,
799
+ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
800
+ });
801
+ }
802
+
803
+ // ------------------------------------------------------- compact header
804
+ function renderMetaTags() {
805
+ const tags = $('#f-tags').value.split(',').map((t) => t.trim()).filter(Boolean);
806
+ const box = $('#meta-tags');
807
+ box.innerHTML = tags.map((t) => `<span class="chip">#${esc(t)}</span>`).join('');
808
+ $$('.chip', box).forEach((c) => c.addEventListener('click', () => toggleDetails(true)));
809
+ const hasDesc = $('#f-description').value.trim().length > 0;
810
+ const open = !$('#details').hidden;
811
+ $('#btn-details').textContent = (open ? 'Hide details ▴' : 'Details ▾') + (!open && hasDesc ? ' •' : '');
812
+ $('#btn-details').title = hasDesc && !open ? 'Has a description. Click to view or edit tags and description' : 'Edit tags and description';
813
+ }
814
+ function toggleDetails(force) {
815
+ const open = force === undefined ? $('#details').hidden : force;
816
+ $('#details').hidden = !open;
817
+ try { localStorage.setItem('pyhttp.details', open ? '1' : '0'); } catch { /* storage unavailable */ }
818
+ renderMetaTags();
819
+ if (open && force === true) $('#f-tags').focus();
820
+ }
821
+
822
+ // Deleting a request is undoable: the row disappears at once, the server delete fires after a
823
+ // grace period, and Undo on the toast brings it back untouched (same id, history intact).
824
+ const pendingDeletes = new Map(); // request id -> timer
825
+ const UNDO_MS = 6000;
826
+ function commitPendingDeletes() {
827
+ for (const [id, timer] of pendingDeletes) {
828
+ clearTimeout(timer);
829
+ fetch('/api/requests/' + id, { method: 'DELETE', keepalive: true }).catch(() => {});
830
+ }
831
+ pendingDeletes.clear();
832
+ }
833
+ async function deleteRequestById(id, name) {
834
+ const isCurrent = !!(state.current && state.current.id === id);
835
+ if (isCurrent) {
836
+ clearTimeout(autosave.timer);
837
+ clearTimeout(autosave.retry);
838
+ autosave.dirty = false;
839
+ while (autosave.inFlight) await autosave.inFlight;
840
+ }
841
+ const wasSelected = state.selected.find((x) => x.id === id) || null;
842
+ state.selected = state.selected.filter((x) => x.id !== id);
843
+ state.requests = state.requests.filter((r) => r.id !== id);
844
+ if (state.kbId === id) state.kbId = null;
845
+ tabsRemove(id);
846
+ if (isCurrent) { state.current = null; renderEditor(); }
847
+ renderList();
848
+ pendingDeletes.set(id, setTimeout(async () => {
849
+ pendingDeletes.delete(id);
850
+ try { await api('/requests/' + id, { method: 'DELETE' }); }
851
+ catch (e) { toast(e.message, 'error'); loadRequests(); }
852
+ }, UNDO_MS));
853
+ toast(`Deleted “${name}”`, '', { label: 'Undo', onClick: async () => {
854
+ const timer = pendingDeletes.get(id);
855
+ if (timer === undefined) return;
856
+ clearTimeout(timer);
857
+ pendingDeletes.delete(id);
858
+ if (wasSelected) state.selected.push(wasSelected);
859
+ await loadRequests();
860
+ openRequest(id);
861
+ } });
862
+ }
863
+ async function duplicateRequestById(id) {
864
+ await saveNow();
865
+ try {
866
+ const copy = await api(`/requests/${id}/duplicate`, { method: 'POST' });
867
+ await loadRequests();
868
+ await openRequest(copy.id);
869
+ toast('Duplicated');
870
+ } catch (e) { toast(e.message, 'error'); }
871
+ }
872
+
873
+ async function sendCurrent() {
874
+ if (!state.current) return;
875
+ await saveNow();
876
+ const data = readEditor();
877
+ data.folder_id = state.current.folder_id || null;
878
+ let enabled = data.body_type === 'none' ? [] : data.bodies.filter((b) => b.enabled);
879
+ if (state.sending && state.sending.key === responseKey()) return;
880
+ const viewing = data.body_type === 'none' ? null : data.bodies.find((b) => b.id === state.activeBodyId);
881
+ if (viewing && !viewing.enabled) {
882
+ const what = enabled.length === 1 ? `the checked body ${strong(enabled[0].name)}`
883
+ : enabled.length ? `the ${enabled.length} checked bodies` : 'no body (none is checked)';
884
+ const choice = await dialog({
885
+ title: 'Send which body?',
886
+ message: `You are viewing ${strong(viewing.name)}, but it is not checked, so Send would use ${what}. Only checked bodies are sent; the checkbox next to a body picks it.`,
887
+ buttons: [
888
+ { label: 'Cancel', value: null, cancel: true },
889
+ { label: enabled.length ? 'Send checked' : 'Send without a body', value: 'checked' },
890
+ { label: `Send “${viewing.name}”`, value: 'viewing', cls: 'primary', primary: true },
891
+ ],
892
+ });
893
+ if (!choice) return;
894
+ if (choice === 'viewing') {
895
+ // this send only: the checkboxes stay as they are
896
+ data.bodies = data.bodies.map((b) => ({ ...b, enabled: b.id === viewing.id }));
897
+ enabled = data.bodies.filter((b) => b.enabled);
898
+ }
899
+ }
900
+ // pre-flight: warn about {{variables}} that have no value (like Postman's red variables)
901
+ try {
902
+ const pre = await api('/resolve?body_id=' + encodeURIComponent(enabled[0] ? enabled[0].id : ''), { method: 'POST', body: { ...data, id: state.current.id } });
903
+ // a dependency (e.g. a token request) usually supplies the missing values, so no warning in that case
904
+ if (pre.unresolved && pre.unresolved.length && !appSettings.skip_unresolved_warning && !(data.options && data.options.depends)) {
905
+ const ok = await confirmDialog('Unresolved variables', `No value is defined for ${pre.unresolved.map((u) => `<code>{{${esc(u)}}}</code>`).join(' ')}. They will be sent as-is. Define them in Variables or the active environment, or send anyway?`, { okLabel: 'Send anyway' });
906
+ if (!ok) return;
907
+ }
908
+ } catch (e) { toast('Could not prepare the request: ' + e.message, 'error'); return; }
909
+ const sendKey = responseKey();
910
+ state.sending = { kind: 'single', key: sendKey, id: 's' + Math.random().toString(36).slice(2, 12) };
911
+ renderSendButton();
912
+ const rs = responsesFor(true);
913
+ try {
914
+ if (enabled.length > 1) {
915
+ // one send per checked body; every output is saved to a run folder
916
+ delete rs.byKey[NO_BODY];
917
+ enabled.forEach((b) => { rs.byKey[b.id] = { pending: true, body_name: b.name }; });
918
+ rs.selected = enabled.some((b) => b.id === state.activeBodyId) ? state.activeBodyId : enabled[0].id;
919
+ renderResponseArea();
920
+ const run = await api('/send-bodies', { method: 'POST', body: { ...data, id: state.current.id } });
921
+ rs.run = { id: run.id, keys: enabled.map((b) => b.id), total: run.total, completed: 0, failed: 0, status: 'running', output_dir: run.output_dir, parallel: !!(run.options && run.options.parallel) };
922
+ state.sending = { kind: 'run', key: sendKey, runId: run.id, total: run.total, done: 0, started: state.sending && state.sending.started };
923
+ watchRun(run.id);
924
+ pollBodyRun(rs);
925
+ } else {
926
+ const key = enabled.length === 1 ? enabled[0].id : NO_BODY;
927
+ const result = data.options && data.options.stream
928
+ ? await sendStreaming({ ...data, id: state.current.id })
929
+ : await api('/send', { method: 'POST', body: { ...data, send_id: state.sending.id } });
930
+ if (result) {
931
+ if (key === NO_BODY) rs.byKey = {}; // sent without a body: old body responses no longer apply
932
+ else delete rs.byKey[NO_BODY]; // sent with a body: drop the old no-body response
933
+ rs.byKey[key] = result;
934
+ rs.selected = key;
935
+ rs.run = null;
936
+ renderResponseArea();
937
+ }
938
+ }
939
+ } catch (e) { toast('Send failed: ' + e.message, 'error'); }
940
+ finally {
941
+ if (state.sending && state.sending.key === sendKey && state.sending.kind === 'single') state.sending = null;
942
+ renderSendButton();
943
+ pollConsole();
944
+ if (typeof refreshHistoryList === 'function') refreshHistoryList();
945
+ if (typeof refreshDependencyUi === 'function') refreshDependencyUi(); // "last sent … ago" in the Settings tab
946
+ }
947
+ }
948
+
949
+ // ------------------------------------------------- responses (one per body)
950
+ // state.responses[requestKey] = { byKey: {bodyId|NO_BODY: result}, selected, run }
951
+ const NO_BODY = '__none__';
952
+ function responseKey() { return state.current ? (state.current.id || 'draft') : null; }
953
+ function responsesFor(create = false) {
954
+ const k = responseKey();
955
+ if (!k) return null;
956
+ if (!state.responses[k] && create) state.responses[k] = { byKey: {}, selected: null, run: null };
957
+ return state.responses[k] || null;
958
+ }
959
+ function moveResponses(from, to) {
960
+ if (state.responses[from]) { state.responses[to] = state.responses[from]; delete state.responses[from]; }
961
+ }
962
+ function runResultToResponse(r) {
963
+ const d = r.detail || {};
964
+ const tests = r.tests || [];
965
+ return {
966
+ status: d.response ? 'ok' : 'error', status_code: r.status_code, reason: d.reason || '',
967
+ duration_ms: r.duration_ms, error: r.error, request: d.request, response: d.response, body_name: r.body_name,
968
+ tests, tests_failed: tests.filter((t) => !t.passed).length, extracted: d.extracted || [], iteration: r.iteration || 1, dependency: d.dependency || null,
969
+ };
970
+ }
971
+ // response keys: a body id or NO_BODY, plus "#n" for iteration n of a run with several iterations
972
+ const bodyOfKey = (k) => String(k).split('#')[0];
973
+ const iterOfKey = (k) => Number(String(k).split('#')[1] || 0);
974
+ function responseKeyFor(req, bodyName, iteration) {
975
+ let id = NO_BODY;
976
+ if (req && (req.body_type || 'none') !== 'none') {
977
+ const bodies = req.bodies || [];
978
+ const b = bodyName ? bodies.find((x) => x.name === bodyName) : (bodies.length === 1 ? bodies[0] : null);
979
+ if (b) id = b.id;
980
+ }
981
+ return iteration ? `${id}#${iteration}` : id;
982
+ }
983
+ // A collection run's results land in the response area of each request it sent: one tab per body and iteration,
984
+ // so the responses are in view beside the request without opening the run report.
985
+ function applyRunResponses(run) {
986
+ if (!run || (run.options && run.options.mode === 'bodies')) return; // "Send" with several bodies has its own polling
987
+ const multiIter = run.options && run.options.iterations > 1;
988
+ const byReq = {};
989
+ (run.results || []).forEach((r) => { if (r.request_id) (byReq[r.request_id] = byReq[r.request_id] || []).push(r); });
990
+ Object.entries(byReq).forEach(([id, list]) => {
991
+ const req = state.current && state.current.id === id
992
+ ? { body_type: $('#f-body-type').value, bodies: state.bodies }
993
+ : state.requests.find((x) => x.id === id);
994
+ if (!state.responses[id]) state.responses[id] = { byKey: {}, selected: null, run: null };
995
+ const rs = state.responses[id];
996
+ if (!rs.run || rs.run.id !== run.id) { rs.byKey = {}; rs.selected = null; rs.run = { id: run.id, keys: [] }; }
997
+ list.forEach((r) => { rs.byKey[responseKeyFor(req, r.body_name, multiIter ? r.iteration : 0)] = runResultToResponse(r); });
998
+ Object.assign(rs.run, { name: run.name || '', completed: run.completed, total: run.total, failed: run.failed, status: run.status, output_dir: run.output_dir, parallel: !!(run.options && run.options.parallel) });
999
+ });
1000
+ if (state.current && byReq[state.current.id]) renderResponseArea();
1001
+ }
1002
+ async function pollBodyRun(rs) {
1003
+ const run = rs.run;
1004
+ if (!run) return;
1005
+ try {
1006
+ const data = await api('/runs/' + run.id);
1007
+ if (rs.run !== run) return; // a newer send replaced this one
1008
+ (data.results || []).forEach((r) => {
1009
+ const key = run.keys[r.position - 1];
1010
+ if (key) rs.byKey[key] = runResultToResponse(r);
1011
+ });
1012
+ Object.assign(run, { completed: data.completed, failed: data.failed, status: data.status });
1013
+ if (state.sending && state.sending.runId === run.id) {
1014
+ state.sending.done = data.completed;
1015
+ if (data.status !== 'running') state.sending = null;
1016
+ renderSendButton();
1017
+ }
1018
+ if (data.status !== 'running') {
1019
+ run.keys.forEach((k) => { if (rs.byKey[k] && rs.byKey[k].pending) rs.byKey[k] = { notSent: true, body_name: rs.byKey[k].body_name }; });
1020
+ }
1021
+ if (responsesFor() === rs) renderResponseArea();
1022
+ if (data.status === 'running') setTimeout(() => pollBodyRun(rs), 400);
1023
+ } catch (e) { toast(e.message, 'error'); }
1024
+ }
1025
+ function renderResponseArea() {
1026
+ const rs = responsesFor();
1027
+ if (rs) { // forget responses of bodies that were deleted
1028
+ Object.keys(rs.byKey).forEach((k) => { if (bodyOfKey(k) !== NO_BODY && !state.bodies.some((b) => b.id === bodyOfKey(k))) delete rs.byKey[k]; });
1029
+ }
1030
+ const keys = rs ? Object.keys(rs.byKey) : [];
1031
+ if (!keys.length) { $('#response').hidden = true; renderSendingUi(); return; }
1032
+ $('#response').hidden = false;
1033
+ // order tabs like the body list, then by iteration; bodies deleted since sending go last
1034
+ const order = [NO_BODY, ...state.bodies.map((b) => b.id)];
1035
+ const rank = (k) => (order.indexOf(bodyOfKey(k)) + 1 || 999) * 10000 + iterOfKey(k);
1036
+ keys.sort((a, b) => rank(a) - rank(b));
1037
+ if (!rs.selected || !rs.byKey[rs.selected]) rs.selected = keys[0];
1038
+ const strip = $('#resp-bodies');
1039
+ strip.hidden = keys.length < 2 && !(rs.run && rs.run.status === 'running');
1040
+ if (!strip.hidden) {
1041
+ const nameOf = (k) => {
1042
+ const id = bodyOfKey(k), n = iterOfKey(k);
1043
+ if (id === NO_BODY) return n ? `Iteration ${n}` : 'No body';
1044
+ const b = state.bodies.find((x) => x.id === id);
1045
+ return ((b && b.name) || rs.byKey[k].body_name || 'Body') + (n ? ` #${n}` : '');
1046
+ };
1047
+ const tab = (k) => {
1048
+ const r = rs.byKey[k];
1049
+ let meta = '';
1050
+ if (r.pending) meta = '<span class="rb-ms">sending…</span>';
1051
+ else if (r.notSent) meta = '<span class="rb-ms">not sent</span>';
1052
+ else meta = `<span class="rb-code ${codeClass(r.status_code)}">${r.status_code ?? 'ERR'}</span><span class="rb-ms">${fmtMs(r.duration_ms)}</span>`;
1053
+ const title = iterOfKey(k) ? `Show the response from iteration ${iterOfKey(k)}` : `Show the response for “${esc(nameOf(k))}”`;
1054
+ return `<button class="rb-tab ${k === rs.selected ? 'active' : ''} ${r.pending || r.notSent ? 'pending' : ''}" data-key="${esc(k)}" title="${title}">${esc(nameOf(k))}${meta}</button>`;
1055
+ };
1056
+ const runInfo = rs.run
1057
+ ? `<span class="rb-run">${rs.run.name !== undefined ? `Run${rs.run.name ? ` “${esc(rs.run.name)}”` : ''}${rs.run.status === 'running' ? ' (running)' : ''}: ` : ''}${rs.run.completed}/${rs.run.total} sent${rs.run.parallel ? ' in parallel' : ''}${rs.run.failed ? ` · ${rs.run.failed} failed` : ''}
1058
+ <button class="link small rb-folder" title="${esc(rs.run.output_dir || '')}">Open output folder</button></span>`
1059
+ : '';
1060
+ strip.innerHTML = keys.map(tab).join('') + runInfo;
1061
+ $$('.rb-tab', strip).forEach((el) => el.addEventListener('click', () => {
1062
+ rs.selected = el.dataset.key;
1063
+ renderResponseArea();
1064
+ const bid = bodyOfKey(rs.selected);
1065
+ if (state.bodies.some((b) => b.id === bid) && state.activeBodyId !== bid) {
1066
+ flushBody();
1067
+ state.activeBodyId = bid;
1068
+ renderBodies();
1069
+ }
1070
+ }));
1071
+ const folderBtn = $('.rb-folder', strip);
1072
+ if (folderBtn) folderBtn.addEventListener('click', () => openRunFolder(rs.run.id));
1073
+ }
1074
+ const r = rs.byKey[rs.selected];
1075
+ const head = $('#response .response-head');
1076
+ const panels = $('#resp-panels');
1077
+ let pend = $('#resp-pending');
1078
+ if (r.pending || r.notSent) {
1079
+ head.hidden = true;
1080
+ panels.hidden = true;
1081
+ if ($('#r-tip')) $('#r-tip').hidden = true;
1082
+ if (!pend) { pend = document.createElement('div'); pend.id = 'resp-pending'; pend.className = 'resp-pending'; panels.after(pend); }
1083
+ pend.hidden = false;
1084
+ pend.textContent = r.pending ? `Sending “${r.body_name}”…` : `“${r.body_name}” was not sent (the run stopped first).`;
1085
+ return;
1086
+ }
1087
+ head.hidden = false;
1088
+ panels.hidden = false;
1089
+ if (pend) pend.hidden = true;
1090
+ renderResponse(r, !strip.hidden);
1091
+ }
1092
+ function renderResponse(result, inTabs = false) {
1093
+ $('#response').hidden = false;
1094
+ const bn = $('#r-body-name');
1095
+ const shownBody = bodyOfKey((responsesFor() || {}).selected || '');
1096
+ const otherBody = shownBody && shownBody !== NO_BODY && state.activeBodyId && shownBody !== state.activeBodyId
1097
+ && state.bodies.some((b) => b.id === state.activeBodyId);
1098
+ bn.hidden = (!result.body_name || inTabs) && !otherBody;
1099
+ bn.textContent = result.body_name || (state.bodies.find((b) => b.id === shownBody) || {}).name || '';
1100
+ bn.classList.toggle('other', !!otherBody);
1101
+ bn.title = otherBody ? `This is the response of “${bn.textContent}”, not of the body you are viewing (“${(activeBody() || {}).name || ''}”), which has not been sent yet.` : 'The body this response belongs to';
1102
+ renderMethodTip(result);
1103
+ renderDependencyBadge(result.dependency);
1104
+ const st = $('#r-status');
1105
+ if (result.status === 'cancelled') {
1106
+ st.textContent = 'Cancelled';
1107
+ st.className = 'status warn';
1108
+ st.title = 'The call was stopped before a response arrived';
1109
+ $('#r-time').textContent = '';
1110
+ $('#r-size').textContent = '';
1111
+ } else if (result.status === 'error' || !result.response) {
1112
+ st.textContent = 'Error';
1113
+ st.className = 'status err';
1114
+ st.title = result.error || '';
1115
+ $('#r-time').textContent = fmtMs(result.duration_ms);
1116
+ $('#r-size').textContent = '';
1117
+ } else {
1118
+ const code = result.status_code;
1119
+ st.textContent = `${code} ${result.reason || ''}`.trim();
1120
+ st.className = 'status ' + (code < 300 ? 'ok' : code < 400 ? 'warn' : 'err');
1121
+ st.title = '';
1122
+ $('#r-time').textContent = fmtMs(result.duration_ms);
1123
+ $('#r-size').textContent = fmtBytes(result.response.size);
1124
+ }
1125
+ if ($('#r-body')._lastResult !== result) {
1126
+ $('#r-body')._lastResult = result;
1127
+ renderResponsePanels(result);
1128
+ }
1129
+ }
1130
+
1131
+ // A GET that fails often just needs another method (405 Method Not Allowed, or an API that
1132
+ // expects the body in a POST): suggest one, using the server's Allow header when it sends one.
1133
+ function renderMethodTip(result) {
1134
+ let tip = $('#r-tip');
1135
+ const method = ((result.request && result.request.method) || $('#f-method').value || '').toUpperCase();
1136
+ const code = result.status_code;
1137
+ const httpErr = code >= 400 && ![401, 403, 407, 429].includes(code);
1138
+ if (method !== 'GET' || !(httpErr || (result.status === 'error' && result.response))) { if (tip) tip.hidden = true; return; }
1139
+ if (!tip) {
1140
+ tip = document.createElement('div');
1141
+ tip.id = 'r-tip';
1142
+ tip.className = 'r-tip small';
1143
+ $('#response .response-head').after(tip);
1144
+ }
1145
+ const allowHdr = ((result.response && result.response.headers) || []).find(([k]) => k.toLowerCase() === 'allow');
1146
+ const allowed = allowHdr ? allowHdr[1].split(',').map((m) => m.trim().toUpperCase()).filter((m) => m && m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS') : [];
1147
+ const methods = $$('#f-method option').map((o) => o.value);
1148
+ const suggest = (allowed.filter((m) => methods.includes(m)).length ? allowed.filter((m) => methods.includes(m)) : ['POST', 'PUT', 'PATCH']).slice(0, 3);
1149
+ const hasBody = $('#f-body-type').value !== 'none' && state.bodies.some((b) => b.enabled && (b.body || '').trim());
1150
+ let why = `GET returned ${code ?? 'an error'}.`;
1151
+ if (code === 405) why = `The server does not allow GET here (405 Method Not Allowed)${allowed.length ? `; it accepts ${allowed.map((m) => `<code>${esc(m)}</code>`).join(', ')}` : ''}.`;
1152
+ else if (hasBody) why = `GET returned ${code ?? 'an error'} and this request has a body; most servers ignore a body on GET.`;
1153
+ tip.innerHTML = `<span class="r-tip-i">💡</span><span><strong>Tip:</strong> ${why} Try ${suggest.length > 1 ? 'another method, such as' : ''}</span>` +
1154
+ suggest.map((m) => `<button type="button" class="link small r-tip-m" data-m="${esc(m)}">${esc(m)}</button>`).join('<span class="muted">·</span>') +
1155
+ '<span class="grow"></span><button type="button" class="link small r-tip-x" title="Hide this tip">✕</button>';
1156
+ tip.hidden = false;
1157
+ $$('.r-tip-m', tip).forEach((b) => b.addEventListener('click', () => {
1158
+ const sel = $('#f-method');
1159
+ sel.value = b.dataset.m;
1160
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
1161
+ tip.hidden = true;
1162
+ toast(`Method set to ${b.dataset.m}. Press Send to try it.`, 'ok');
1163
+ }));
1164
+ $('.r-tip-x', tip).addEventListener('click', () => { tip.hidden = true; });
1165
+ }
1166
+
1167
+ // --------------------------------------------------------------------- runs
1168
+ function openRunModal(opts = {}) {
1169
+ const order = (opts.items || state.selected).slice();
1170
+ if (!order.length) return;
1171
+ const env = state.envs.find((e) => e.id === state.activeEnvId);
1172
+ const renderOrder = () => {
1173
+ const ul = $('#order-list');
1174
+ ul.innerHTML = '';
1175
+ order.forEach((s, i) => {
1176
+ const li = document.createElement('li');
1177
+ li.innerHTML =
1178
+ `<span class="muted small">${i + 1}</span>` +
1179
+ `<span class="method m-${esc(s.method)}">${esc(s.method)}</span>` +
1180
+ `<span class="name">${esc(s.name)}${s.n > 1 ? ` <span class="bcount">×${s.n} bodies</span>` : ''}</span>` +
1181
+ `<span class="ctl"><button class="icon" data-act="up" ${i === 0 ? 'disabled' : ''}>▲</button>` +
1182
+ `<button class="icon" data-act="down" ${i === order.length - 1 ? 'disabled' : ''}>▼</button>` +
1183
+ `<button class="icon" data-act="rm" title="Remove from run">✕</button></span>`;
1184
+ $$('button', li).forEach((b) => b.addEventListener('click', () => {
1185
+ if (b.dataset.act === 'up') [order[i - 1], order[i]] = [order[i], order[i - 1]];
1186
+ if (b.dataset.act === 'down') [order[i + 1], order[i]] = [order[i], order[i + 1]];
1187
+ if (b.dataset.act === 'rm') order.splice(i, 1);
1188
+ renderOrder();
1189
+ }));
1190
+ ul.appendChild(li);
1191
+ });
1192
+ const sends = order.reduce((a, s) => a + (s.n || 1), 0);
1193
+ $('#order-total').textContent = `${order.length} request(s), ${sends} send(s) in total.`;
1194
+ };
1195
+ runData.rows = []; runData.columns = []; runData.name = '';
1196
+ // in order / in parallel is one setting (Settings → Requests, Send ▾, this dialog): never a stale per-run value
1197
+ const parallel = !!appSettings.parallel;
1198
+ const concurrency = appSettings.concurrency || 8;
1199
+ openModal(opts.title || 'Run requests', `
1200
+ <label class="field">Run name <input id="run-name" class="grow" placeholder="optional: left empty, the run is named after the time"></label>
1201
+ <div class="run-order">
1202
+ <span class="set-label">How to send</span>
1203
+ <div class="seg" id="run-mode" role="radiogroup">
1204
+ <button type="button" data-mode="seq" class="${parallel ? '' : 'on'}" title="One after another, top to bottom; {{prev.…}} reads the previous response">In order</button>
1205
+ <button type="button" data-mode="par" class="${parallel ? 'on' : ''}" title="Everything at once; fastest, but no order and no {{prev.…}}">In parallel</button>
1206
+ </div>
1207
+ <label class="run-conc" ${parallel ? '' : 'hidden'}>at most <input type="number" id="run-concurrency" min="1" max="64" step="1" value="${concurrency}"> at once</label>
1208
+ <input type="checkbox" id="run-parallel" ${parallel ? 'checked' : ''} hidden>
1209
+ </div>
1210
+ <div class="muted small" id="run-order-hint"></div>
1211
+ <div class="muted small">A request with several checked bodies is sent once per body. Use ▲▼ to reorder. The default is in Settings → Requests.</div>
1212
+ <div id="order-total" class="small" style="margin-top:6px"></div>
1213
+ <div class="small" style="margin-top:4px">Environment: ${env ? `<strong>${esc(env.name)}</strong>` : '<span class="muted">none (global variables only)</span>'}</div>
1214
+ <ul id="order-list" class="order-list"></ul>
1215
+ <div class="opts">
1216
+ <label><input type="checkbox" id="run-stop"> Stop the run at the first failure</label>
1217
+ <label><input type="checkbox" id="run-http-fail" checked> Treat HTTP status ≥ 400 as a failure</label>
1218
+ <label><input type="checkbox" id="run-test-fail" checked> Treat a failing test as a failure</label>
1219
+ <label>Delay between requests <input type="number" id="run-delay" min="0" step="50" value="0"> ms <span class="muted small">(in order only)</span></label>
1220
+ <label>Iterations <input type="number" id="run-iterations" min="1" max="1000" step="1" value="1"> <span class="muted small">run the whole list this many times</span></label>
1221
+ <label><span style="white-space:nowrap">Variables file</span> <button id="run-data-pick" type="button" title="A CSV or JSON file with one row of variable values per iteration">Choose CSV or JSON…</button> <span id="run-data-info" class="small"></span></label>
1222
+ <div class="muted small">Not a request body: each row holds the values of your <code>{{variables}}</code> for one iteration, with a column per variable (also as <code>{{data.column}}</code>).
1223
+ CSV with a header row, or a JSON array of objects. <button class="link small" id="run-data-sample" type="button" title="Save a sample file with a column for each {{variable}} these requests use">Download sample CSV</button>
1224
+ <button class="link small" id="run-data-try" type="button" title="Run with the sample file to see how a variables file works">Use the sample</button></div>
1225
+ </div>
1226
+ `, [
1227
+ { label: 'Cancel', onClick: closeModal },
1228
+ { label: '▶ Start run', cls: 'accent', onClick: async () => {
1229
+ if (!order.length) return toast('Nothing to run', 'error');
1230
+ const body = {
1231
+ request_ids: order.map((s) => s.id),
1232
+ name: $('#run-name').value,
1233
+ stop_on_error: $('#run-stop').checked,
1234
+ fail_on_http_error: $('#run-http-fail').checked,
1235
+ fail_on_test: $('#run-test-fail').checked,
1236
+ delay_ms: Number($('#run-delay').value) || 0,
1237
+ iterations: Math.max(1, Number($('#run-iterations').value) || 1),
1238
+ data: runData.rows,
1239
+ parallel: $('#run-parallel').checked,
1240
+ concurrency: Math.max(1, Number($('#run-concurrency').value) || 8),
1241
+ };
1242
+ rememberRunOptions(body);
1243
+ if (body.parallel !== !!appSettings.parallel || (body.parallel && body.concurrency !== (appSettings.concurrency || 8))) {
1244
+ try { await saveSettings({ parallel: body.parallel, concurrency: body.concurrency }); renderSendButton(); } catch { /* the run still uses the choice */ }
1245
+ }
1246
+ closeModal();
1247
+ await startRun(body);
1248
+ } },
1249
+ ]);
1250
+ renderOrder();
1251
+ const setMode = (par) => {
1252
+ $('#run-parallel').checked = par;
1253
+ $$('#run-mode button').forEach((b) => b.classList.toggle('on', (b.dataset.mode === 'par') === par));
1254
+ $('.run-conc').hidden = !par;
1255
+ $('#run-delay').disabled = par;
1256
+ $('#run-order-hint').textContent = par
1257
+ ? 'Everything is sent at once (bounded by the number above). Fastest, but there is no order, no delay, and {{prev.…}} has no previous response.'
1258
+ : 'Requests are sent one by one, top to bottom; each one can read the previous response with {{prev.…}}.';
1259
+ };
1260
+ $$('#run-mode button').forEach((b) => b.addEventListener('click', () => setMode(b.dataset.mode === 'par')));
1261
+ setMode(parallel);
1262
+ $('#run-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); $('#modal-foot button.accent').click(); } });
1263
+ renderDataFileInfo();
1264
+ $('#run-data-pick').addEventListener('click', pickDataFile);
1265
+ const sampleData = () => api('/runs/sample-data?request_ids=' + encodeURIComponent(order.map((s) => s.id).filter(Boolean).join(',')));
1266
+ $('#run-data-sample').addEventListener('click', async () => {
1267
+ try { const f = await sampleData(); await downloadText(f.text, f.filename); } catch (e) { toast(e.message, 'error'); }
1268
+ });
1269
+ $('#run-data-try').addEventListener('click', async () => {
1270
+ try {
1271
+ const f = await sampleData();
1272
+ runData.rows = f.rows; runData.columns = f.columns; runData.name = f.filename;
1273
+ renderDataFileInfo();
1274
+ } catch (e) { toast(e.message, 'error'); }
1275
+ });
1276
+ $('#run-name').value = opts.name || '';
1277
+ $('#run-name').focus();
1278
+ }
1279
+
1280
+ // The options of the last run (not the name or the variables file), so "Run now" can skip the dialog.
1281
+ // Parallel and concurrency are not among them: they come from the settings, so an old run cannot override them.
1282
+ const RUN_OPTION_KEYS = ['stop_on_error', 'fail_on_http_error', 'fail_on_test', 'delay_ms', 'iterations'];
1283
+ function lastRunOptions() {
1284
+ try { return JSON.parse(localStorage.getItem('pyhttp.runOptions') || '{}') || {}; } catch { return {}; }
1285
+ }
1286
+ function rememberRunOptions(body) {
1287
+ const keep = {};
1288
+ RUN_OPTION_KEYS.forEach((k) => { keep[k] = body[k]; });
1289
+ try { localStorage.setItem('pyhttp.runOptions', JSON.stringify(keep)); } catch { /* storage unavailable */ }
1290
+ }
1291
+ async function startRun(body) {
1292
+ try {
1293
+ const run = await api('/runs', { method: 'POST', body });
1294
+ watchRun(run.id);
1295
+ showTab($('#runs-tabs'), 'current');
1296
+ } catch (e) { toast(e.message, 'error'); }
1297
+ }
1298
+ // "Run now": the selected requests straight away, with the last run's options and no name.
1299
+ // Replay the ⚡ flash on every press (the class is removed again when the animation ends).
1300
+ function zap(btn) {
1301
+ if (!btn) return;
1302
+ btn.classList.remove('zap');
1303
+ void btn.offsetWidth; // restart the animation
1304
+ btn.classList.add('zap');
1305
+ btn.addEventListener('animationend', () => btn.classList.remove('zap'), { once: true });
1306
+ }
1307
+ function runSelectedNow() {
1308
+ if (!state.selected.length) return;
1309
+ zap($('#btn-run-now'));
1310
+ const n = state.selected.reduce((a, s) => a + (s.n || 1), 0);
1311
+ toast(`⚡ Running ${state.selected.length} request${state.selected.length === 1 ? '' : 's'} now (${n} send${n === 1 ? '' : 's'})`, 'ok');
1312
+ const last = lastRunOptions();
1313
+ startRun({ request_ids: state.selected.map((s) => s.id), name: '', parallel: !!appSettings.parallel, concurrency: appSettings.concurrency || 8,
1314
+ ...Object.fromEntries(RUN_OPTION_KEYS.filter((k) => k in last).map((k) => [k, last[k]])), iterations: 1, data: [] });
1315
+ }
1316
+
1317
+ function watchRun(runId) {
1318
+ clearTimeout(state.pollTimer);
1319
+ state.activeRunId = runId;
1320
+ if (typeof setRunsPanel === 'function' && !runsPanelShown()) setRunsPanel(true);
1321
+ pollRun();
1322
+ }
1323
+ async function pollRun() {
1324
+ if (!state.activeRunId) return;
1325
+ try {
1326
+ const run = await api('/runs/' + state.activeRunId);
1327
+ renderCurrentRun(run);
1328
+ applyRunResponses(run);
1329
+ if (run.status === 'running') {
1330
+ state.pollTimer = setTimeout(pollRun, 600);
1331
+ } else {
1332
+ loadHistory();
1333
+ if (typeof refreshHistoryList === 'function') refreshHistoryList();
1334
+ const msg = `Run ${run.status}: ${run.completed}/${run.total} sent, ${run.failed} failed`;
1335
+ toast(msg, run.failed || run.status !== 'completed' ? 'error' : 'ok');
1336
+ }
1337
+ } catch (e) { toast(e.message, 'error'); }
1338
+ }
1339
+
1340
+ function renderCurrentRun(run) {
1341
+ const box = $('#run-current');
1342
+ const pct = run.total ? Math.round((run.completed / run.total) * 100) : 0;
1343
+ const results = run.results || [];
1344
+ const testsTotal = results.reduce((a, r) => a + (r.tests || []).length, 0);
1345
+ const testsFailed = results.reduce((a, r) => a + (r.tests || []).filter((t) => !t.passed).length, 0);
1346
+ const iterations = run.options && run.options.iterations > 1 ? run.options.iterations : 0;
1347
+ const failedOnly = box.dataset.failedOnly === '1';
1348
+ const head = `
1349
+ <div class="run-head">
1350
+ <div class="title"><span class="grow">${esc(run.name)}</span>${run.options && run.options.environment ? `<span class="run-env" title="Environment">${esc(run.options.environment)}</span>` : ''}<span class="badge ${esc(run.status)}">${esc(run.status)}</span></div>
1351
+ <div class="progress ${run.failed ? 'has-fail' : ''}"><div style="width:${pct}%"></div></div>
1352
+ <div class="muted small">${run.completed}/${run.total} sent · ${run.failed} failed${testsTotal ? ` · tests <span class="${testsFailed ? 'err-text' : 'ok-text'}">${testsTotal - testsFailed}/${testsTotal} passed</span>` : ''}${iterations ? ` · ${iterations} iterations` : ''}${run.options && run.options.parallel ? ' · in parallel' : ''} · started ${fmtDate(run.started_at)}</div>
1353
+ <div class="path" title="Output folder">${esc(run.output_dir)}</div>
1354
+ <div class="run-actions">
1355
+ ${run.status === 'running' ? '<button id="btn-cancel-run" class="danger">Cancel run</button>' : ''}
1356
+ <button id="btn-open-folder">Open folder</button>
1357
+ <a class="link" href="/api/runs/${run.id}/files/summary.json" target="_blank"><button>summary.json</button></a>
1358
+ <button id="btn-report-html" title="Save an HTML report of this run">Report (HTML)</button>
1359
+ <button id="btn-report-json" title="Save the full results as JSON">Report (JSON)</button>
1360
+ <label class="small failed-filter"><input type="checkbox" id="run-failed-only" ${failedOnly ? 'checked' : ''}> Failed only</label>
1361
+ </div>
1362
+ </div>`;
1363
+ box.innerHTML = head + `<ul class="result-list">${resultItemsHtml(run, failedOnly)}</ul>`;
1364
+ const cancelBtn = $('#btn-cancel-run', box);
1365
+ if (cancelBtn) cancelBtn.addEventListener('click', async () => {
1366
+ await api(`/runs/${run.id}/cancel`, { method: 'POST' });
1367
+ toast('Cancelling…');
1368
+ });
1369
+ $('#btn-open-folder', box).addEventListener('click', () => openRunFolder(run.id));
1370
+ $('#btn-report-html', box).addEventListener('click', () => exportRunReport(run.id, 'html'));
1371
+ $('#btn-report-json', box).addEventListener('click', () => exportRunReport(run.id, 'json'));
1372
+ $('#run-failed-only', box).addEventListener('change', (e) => { box.dataset.failedOnly = e.target.checked ? '1' : '0'; renderCurrentRun(run); });
1373
+ bindResultClicks(box, run);
1374
+ }
1375
+ async function openRunFolder(runId) {
1376
+ try { await api(`/runs/${runId}/open-folder`, { method: 'POST' }); }
1377
+ catch (e) { toast(e.message, 'error'); }
1378
+ }
1379
+ function resultItemsHtml(run, failedOnly = false) {
1380
+ const results = run.results || [];
1381
+ const rows = results.filter((r) => !failedOnly || r.status === 'failed').map((r) => resultRow(run, r));
1382
+ if (failedOnly && !rows.length) rows.push('<li class="empty small">No failed requests.</li>');
1383
+ // in order: only the next one is in flight; in parallel: up to `concurrency` of them at once
1384
+ const opts = run.options || {};
1385
+ const inFlight = opts.parallel ? Math.max(1, Math.min(64, opts.concurrency || 8)) : 1;
1386
+ for (let i = results.length + 1; i <= run.total && !failedOnly; i++) {
1387
+ const now = run.status === 'running' && i <= results.length + inFlight;
1388
+ const label = now ? 'sending…' : run.status === 'running' ? 'waiting' : 'not sent';
1389
+ rows.push(`<li class="result-item pending ${now ? 'active-now' : ''}"><span class="pos">${i}</span><span class="name muted">${label}</span><span></span></li>`);
1390
+ }
1391
+ return rows.join('');
1392
+ }
1393
+ function bindResultClicks(box, run) {
1394
+ $$('.result-item[data-rid]', box).forEach((li) => li.addEventListener('click', () => {
1395
+ const r = (run.results || []).find((x) => x.id === li.dataset.rid);
1396
+ if (r) openResultModal(run, r);
1397
+ }));
1398
+ }
1399
+ function resultRow(run, r) {
1400
+ const code = r.status_code;
1401
+ const codeCls = code == null ? 'err' : code < 300 ? 'ok' : code < 400 ? 'warn' : 'err';
1402
+ const tests = r.tests || [];
1403
+ const tf = tests.filter((t) => !t.passed).length;
1404
+ const iter = run.options && run.options.iterations > 1 ? `<span class="iter" title="Iteration">#${r.iteration || 1}</span>` : '';
1405
+ return `<li class="result-item ${r.status === 'failed' ? 'failed' : ''}" data-rid="${r.id}">
1406
+ <span class="pos">${r.position}</span>
1407
+ <span class="name">${iter}<span class="method m-${esc(r.method)}">${esc(r.method)}</span> ${esc(r.request_name)}${r.body_name ? ` <span class="bn">· ${esc(r.body_name)}</span>` : ''}</span>
1408
+ <span class="meta">${tests.length ? `<span class="tcount ${tf ? 'fail' : 'pass'}" title="${tests.length - tf} of ${tests.length} tests passed">${tf ? '✕' : '✓'} ${tests.length - tf}/${tests.length}</span>` : ''}<span class="status ${codeCls}">${code == null ? 'ERR' : code}</span><span class="muted">${fmtMs(r.duration_ms)}</span></span>
1409
+ </li>`;
1410
+ }
1411
+
1412
+ function openResultModal(run, r) {
1413
+ const d = r.detail || {};
1414
+ const resp = d.response;
1415
+ // the two files a result writes to the run folder, each with what it holds (the names alone confuse people)
1416
+ const fileRow = (f, what) => f ? `<div class="file-row"><a class="mono" href="/api/runs/${run.id}/files/${encodeURIComponent(f)}" target="_blank">${esc(f)}</a><span class="muted small">${what}</span></div>` : '';
1417
+ const files = fileRow(r.detail_file, 'Report of this send: the request as sent, status, timing, response headers, tests and extracted variables (JSON).')
1418
+ + fileRow(r.body_file, 'The raw response body only, exactly as the server returned it (open it in a browser or editor).');
1419
+ const code = r.status_code;
1420
+ const codeCls = code == null ? 'err' : code < 300 ? 'ok' : code < 400 ? 'warn' : 'err';
1421
+ const tests = r.tests || [];
1422
+ const ext = d.extracted || [];
1423
+ openModal(`${r.position}. ${r.request_name}${r.body_name ? ' · ' + r.body_name : ''}${run.options && run.options.iterations > 1 ? ` (iteration ${r.iteration || 1})` : ''}`, `
1424
+ <div class="row">
1425
+ <span class="status ${codeCls}">${code == null ? 'Error' : `${code} ${esc(d.reason || '')}`}</span>
1426
+ <span class="badge ${esc(r.status)}">${esc(r.status)}</span>
1427
+ <span class="muted small">${fmtMs(r.duration_ms)}</span>
1428
+ <span class="muted small">${resp ? fmtBytes(resp.size) : ''}</span>
1429
+ <span class="grow"></span>
1430
+ ${r.request_id ? `<button class="small" id="res-goto">Open request</button>` : ''}
1431
+ </div>
1432
+ ${r.error ? `<pre class="mono err-text">${esc(r.error)}</pre>` : ''}
1433
+ ${tests.length ? `<div class="detail-section"><h4>Tests</h4><ul class="test-results">${tests.map((t) => `<li class="${t.passed ? 'pass' : 'fail'}"><span class="mark">${t.passed ? '✓' : '✕'}</span><span class="tn">${esc(t.name)}</span><span class="note muted small">${esc(t.note || '')}</span></li>`).join('')}</ul></div>` : ''}
1434
+ ${ext.length ? `<div class="detail-section"><h4>Extracted variables</h4><ul class="test-results">${ext.map((e) => `<li class="${e.ok ? 'pass' : 'fail'}"><span class="mark">${e.ok ? '✓' : '✕'}</span><span class="tn mono">${esc(e.name)}${e.ok ? ' = ' + esc(String(e.value).slice(0, 120)) : ''}</span><span class="note muted small">${esc(e.ok ? e.scope : e.note)}</span></li>`).join('')}</ul></div>` : ''}
1435
+ <div class="detail-section"><h4>Files</h4><div class="files">${files || '<span class="muted">none</span>'}</div></div>
1436
+ <div class="detail-section"><h4>Response body</h4><div id="res-viewer"></div></div>
1437
+ <div class="detail-section"><h4>Response headers</h4><pre class="mono">${esc((resp ? resp.headers : []).map(([k, v]) => `${k}: ${v}`).join('\n'))}</pre></div>
1438
+ <div class="detail-section"><h4>Request as sent</h4><pre class="mono">${esc(formatRequest(d.request))}</pre></div>
1439
+ `, [{ label: 'Close', onClick: closeModal }]);
1440
+ if (resp) mountViewer($('#res-viewer'), resp); else $('#res-viewer').innerHTML = '<div class="muted small">No response</div>';
1441
+ const go = $('#res-goto');
1442
+ if (go) go.addEventListener('click', () => { closeModal(); openRequest(r.request_id).catch(() => toast('That request no longer exists', 'error')); });
1443
+ }
1444
+
1445
+ async function loadHistory() {
1446
+ const box = $('#run-history');
1447
+ try {
1448
+ const runs = await api('/runs');
1449
+ if (!runs.length) { box.innerHTML = '<div class="empty small">No runs yet.</div>'; return; }
1450
+ box.innerHTML = runs.map((r) => `
1451
+ <div class="history-item" data-id="${r.id}">
1452
+ <div class="line"><span class="name">${esc(r.name)}</span><span class="badge ${esc(r.status)}">${esc(r.status)}</span>
1453
+ <button class="icon link" data-del="${r.id}" title="Delete from run history (files are kept)">✕</button></div>
1454
+ <div class="line muted small"><span>${fmtDate(r.started_at)}</span><span>· ${r.completed}/${r.total} sent</span><span>· ${r.failed} failed</span>${r.options && r.options.environment ? `<span>· ${esc(r.options.environment)}</span>` : ''}</div>
1455
+ </div>`).join('');
1456
+ $$('.history-item', box).forEach((el) => el.addEventListener('click', () => {
1457
+ watchRun(el.dataset.id);
1458
+ showTab($('#runs-tabs'), 'current');
1459
+ }));
1460
+ $$('button[data-del]', box).forEach((b) => b.addEventListener('click', async (e) => {
1461
+ e.stopPropagation();
1462
+ if (!await confirmDialog('Remove run from run history?', 'The run is removed from this list. Its output files stay on disk.', { okLabel: 'Remove', danger: true })) return;
1463
+ try { await api('/runs/' + b.dataset.del, { method: 'DELETE' }); loadHistory(); }
1464
+ catch (err) { toast(err.message, 'error'); }
1465
+ }));
1466
+ } catch (e) { box.innerHTML = `<div class="empty small">${esc(e.message)}</div>`; }
1467
+ }
1468
+
1469
+ // ---------------------------------------------------------------- variables
1470
+ async function openVariables() {
1471
+ const vars = await api('/variables');
1472
+ const envNow = state.envs.find((e) => e.id === state.activeEnvId);
1473
+ openModal('Global variables', `
1474
+ <p class="muted small">Use anywhere as <code>{{name}}</code>. Inside a run, <code>{{prev.json.path}}</code>, <code>{{prev.status}}</code>, <code>{{prev.body}}</code> and <code>{{prev.headers.name}}</code> read the previous response.</p>
1475
+ <p class="muted small">Global variables apply in every environment. ${envNow ? `Values in the active environment ${strong(envNow.name)} override these.` : 'Pick an environment in the top bar to override them per stage (Dev, QA, Prod…).'}</p>
1476
+ <div id="kv-vars" class="kv"></div>
1477
+ `, [
1478
+ { label: 'Cancel', onClick: closeModal },
1479
+ { label: 'Save', cls: 'primary', onClick: async () => {
1480
+ const obj = {};
1481
+ readKv($('#kv-vars')).forEach((r) => { if (r.key.trim()) obj[r.key.trim()] = r.value; });
1482
+ try { await api('/variables', { method: 'PUT', body: { variables: obj } }); closeModal(); toast('Variables saved', 'ok'); }
1483
+ catch (e) { toast(e.message, 'error'); }
1484
+ } },
1485
+ ]);
1486
+ renderKv($('#kv-vars'), Object.entries(vars).map(([key, value]) => ({ key, value })), false);
1487
+ }
1488
+
1489
+ // ------------------------------------------------------------ import/export
1490
+ const native = () => window.pywebview && window.pywebview.api;
1491
+
1492
+ async function exportAll() {
1493
+ const data = await api('/export');
1494
+ const text = JSON.stringify(data, null, 2);
1495
+ const filename = `pyhttp-export-${new Date().toISOString().slice(0, 10)}.json`;
1496
+ if (native()) {
1497
+ try {
1498
+ const path = await native().save_file(text, filename);
1499
+ if (path) toast('Exported to ' + path, 'ok');
1500
+ } catch (e) { toast('Export failed: ' + e.message, 'error'); }
1501
+ return;
1502
+ }
1503
+ const blob = new Blob([text], { type: 'application/json' });
1504
+ const a = document.createElement('a');
1505
+ a.href = URL.createObjectURL(blob);
1506
+ a.download = filename;
1507
+ a.click();
1508
+ URL.revokeObjectURL(a.href);
1509
+ }
1510
+ async function importClicked() { importDialog(); }
1511
+ async function importFile(file) {
1512
+ const text = await file.text();
1513
+ if (/^\s*curl(\.exe)?\s/i.test(text)) await importCurl(text, state.current ? state.current.folder_id : null);
1514
+ else await importText(text);
1515
+ }
1516
+ async function importText(text) {
1517
+ let data;
1518
+ try { data = JSON.parse(text); } catch {
1519
+ // not JSON: an OpenAPI spec in YAML?
1520
+ try { data = (await api('/import/parse', { method: 'POST', body: { text } })).data; }
1521
+ catch (e) { toast('Import failed: the file is not valid JSON or YAML (or a curl command)', 'error'); return; }
1522
+ }
1523
+ try {
1524
+ const kind = await api('/import/detect', { method: 'POST', body: { data } });
1525
+ if (['postman_collection', 'postman_environment', 'openapi', 'har'].includes(kind.kind)) { await importPostman(data, kind.kind, kind); return; }
1526
+ if (kind.kind === 'unknown') { toast('Import failed: not a pyhttp export, Postman collection or environment, OpenAPI / Swagger spec or HAR file', 'error'); return; }
1527
+ } catch (e) { toast('Import failed: ' + e.message, 'error'); return; }
1528
+ const requests = Array.isArray(data) ? data : (data.requests || []);
1529
+ const folders = Array.isArray(data) ? [] : (data.folders || []);
1530
+ const environments = Array.isArray(data) ? [] : (data.environments || []);
1531
+ const variables = Array.isArray(data) ? {} : (data.variables || {});
1532
+ const what = [`${requests.length} request(s)`];
1533
+ if (folders.length) what.push(`${folders.length} folder(s)`);
1534
+ if (environments.length) what.push(`${environments.length} environment(s)`);
1535
+ const choice = await dialog({
1536
+ title: 'Import collection',
1537
+ message: `The file contains ${strong(what.join(', '))}. Add them to your collection, or replace everything you have now?`,
1538
+ buttons: [
1539
+ { label: 'Cancel', value: null, cancel: true },
1540
+ { label: 'Replace everything', value: 'replace', cls: 'danger' },
1541
+ { label: 'Add to collection', value: 'add', cls: 'primary', primary: true },
1542
+ ],
1543
+ });
1544
+ if (!choice) return;
1545
+ const replace = choice === 'replace';
1546
+ if (replace && !await confirmDialog('Replace everything?', 'All current requests, folders and environments are deleted and replaced by the file. This cannot be undone.', { okLabel: 'Replace everything', danger: true })) return;
1547
+ try {
1548
+ const res = await api('/import', { method: 'POST', body: { requests, folders, environments, variables, replace } });
1549
+ toast(`Imported ${res.imported} request(s)` + (res.folders ? `, ${res.folders} folder(s)` : '') + (res.environments ? `, ${res.environments} environment(s)` : ''), 'ok');
1550
+ state.selected = [];
1551
+ if (replace && state.current) { autosave.dirty = false; state.current = null; renderEditor(); }
1552
+ await Promise.all([loadRequests(), loadEnvironments()]);
1553
+ } catch (e) { toast('Import failed: ' + e.message, 'error'); }
1554
+ }
1555
+
1556
+ // ================================================================ dialogs
1557
+ // In-app replacements for confirm()/prompt(). All return promises.
1558
+ function dialog({ title, message = '', field = null, buttons, danger = false }) {
1559
+ return new Promise((resolve) => {
1560
+ const prevFocus = document.activeElement;
1561
+ const back = document.createElement('div');
1562
+ back.className = 'dialog-backdrop';
1563
+ let fieldHtml = '';
1564
+ if (field && field.options) {
1565
+ fieldHtml = `<div class="dialog-field">${field.label ? `<label for="dlg-in">${esc(field.label)}</label>` : ''}
1566
+ <select id="dlg-in">${field.options.map((o) => `<option value="${esc(o.value)}" ${o.value === field.value ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select></div>`;
1567
+ } else if (field) {
1568
+ fieldHtml = `<div class="dialog-field">${field.label ? `<label for="dlg-in">${esc(field.label)}</label>` : ''}
1569
+ <input id="dlg-in" value="${esc(field.value || '')}" placeholder="${esc(field.placeholder || '')}" spellcheck="false" autocomplete="off"></div>`;
1570
+ }
1571
+ back.innerHTML = `
1572
+ <div class="dialog ${danger ? 'danger' : ''}" role="alertdialog" aria-modal="true" aria-labelledby="dlg-title">
1573
+ <div class="dialog-head"><span class="dialog-icon">${danger ? '!' : field ? '✎' : '?'}</span><h3 id="dlg-title">${esc(title)}</h3></div>
1574
+ ${message ? `<div class="dialog-msg">${message}</div>` : ''}
1575
+ ${fieldHtml}
1576
+ <div class="dialog-foot"></div>
1577
+ </div>`;
1578
+ const foot = $('.dialog-foot', back);
1579
+ const input = $('#dlg-in', back);
1580
+ let primary = null;
1581
+ const done = (value) => {
1582
+ back.remove();
1583
+ if (prevFocus && prevFocus.focus) prevFocus.focus();
1584
+ resolve(value);
1585
+ };
1586
+ const cancelValue = (buttons.find((b) => b.cancel) || {}).value ?? null;
1587
+ buttons.forEach((b) => {
1588
+ const el = document.createElement('button');
1589
+ el.textContent = b.label;
1590
+ el.className = b.cls || '';
1591
+ el.addEventListener('click', () => {
1592
+ if (input && b.value === true) {
1593
+ const v = input.value.trim();
1594
+ if (!v && !field.options) { input.focus(); return; }
1595
+ done(v);
1596
+ } else done(b.value);
1597
+ });
1598
+ foot.appendChild(el);
1599
+ if (b.primary) primary = el;
1600
+ });
1601
+ back.addEventListener('mousedown', (e) => { if (e.target === back) done(cancelValue); });
1602
+ back.addEventListener('keydown', (e) => {
1603
+ e.stopPropagation();
1604
+ if (e.key === 'Escape') { e.preventDefault(); done(cancelValue); }
1605
+ if (e.key === 'Enter' && primary && e.target.tagName !== 'BUTTON') { e.preventDefault(); primary.click(); }
1606
+ if (e.key === 'Tab') { // keep focus inside the dialog
1607
+ const f = $$('button, input, select', back);
1608
+ const i = f.indexOf(document.activeElement);
1609
+ if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus(); }
1610
+ else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
1611
+ }
1612
+ });
1613
+ document.body.appendChild(back);
1614
+ if (input) { input.focus(); if (input.select) input.select(); } else (primary || $('button', foot)).focus();
1615
+ });
1616
+ }
1617
+ async function confirmDialog(title, message, { okLabel = 'OK', danger = false } = {}) {
1618
+ const v = await dialog({ title, message, danger, buttons: [
1619
+ { label: 'Cancel', value: false, cancel: true },
1620
+ { label: okLabel, value: true, cls: danger ? 'danger-solid' : 'primary', primary: true },
1621
+ ] });
1622
+ return v === true;
1623
+ }
1624
+ function promptDialog(title, { label = '', value = '', placeholder = '', okLabel = 'Save', message = '' } = {}) {
1625
+ return dialog({ title, message, field: { label, value, placeholder }, buttons: [
1626
+ { label: 'Cancel', value: null, cancel: true },
1627
+ { label: okLabel, value: true, cls: 'primary', primary: true },
1628
+ ] });
1629
+ }
1630
+ function selectDialog(title, { label = '', options, value, okLabel = 'OK', message = '' }) {
1631
+ return dialog({ title, message, field: { label, options, value }, buttons: [
1632
+ { label: 'Cancel', value: null, cancel: true },
1633
+ { label: okLabel, value: true, cls: 'primary', primary: true },
1634
+ ] });
1635
+ }
1636
+ const strong = (s) => `<strong>${esc(s)}</strong>`;
1637
+
1638
+ // ================================================================ folders
1639
+ function saveExpanded() {
1640
+ try { localStorage.setItem('pyhttp.expanded', JSON.stringify([...state.expanded])); } catch { /* ignore */ }
1641
+ }
1642
+ function loadExpanded() {
1643
+ try { state.expanded = new Set(JSON.parse(localStorage.getItem('pyhttp.expanded') || '[]')); } catch { state.expanded = new Set(); }
1644
+ }
1645
+ const folderById = (id) => state.folders.find((f) => f.id === id) || null;
1646
+ function folderPath(id) {
1647
+ const parts = [];
1648
+ let f = folderById(id);
1649
+ const seen = new Set();
1650
+ while (f && !seen.has(f.id)) { seen.add(f.id); parts.unshift(f.name); f = folderById(f.parent_id); }
1651
+ return parts;
1652
+ }
1653
+ function folderDescendants(id) {
1654
+ const out = new Set([id]);
1655
+ let grew = true;
1656
+ while (grew) {
1657
+ grew = false;
1658
+ state.folders.forEach((f) => { if (f.parent_id && out.has(f.parent_id) && !out.has(f.id)) { out.add(f.id); grew = true; } });
1659
+ }
1660
+ return out;
1661
+ }
1662
+ function expandTo(folderId) {
1663
+ let f = folderById(folderId);
1664
+ while (f) { state.expanded.add(f.id); f = folderById(f.parent_id); }
1665
+ saveExpanded();
1666
+ }
1667
+ function folderOptions(excludeIds = new Set()) {
1668
+ const opts = [{ value: '', label: '— Top level (no folder) —' }];
1669
+ const walk = (parent, depth) => {
1670
+ state.folders.filter((f) => (f.parent_id || '') === parent && !excludeIds.has(f.id))
1671
+ .forEach((f) => { opts.push({ value: f.id, label: `${'   '.repeat(depth)}📁 ${f.name}` }); walk(f.id, depth + 1); });
1672
+ };
1673
+ walk('', 0);
1674
+ return opts;
1675
+ }
1676
+ function renderCrumb() {
1677
+ const el = $('#crumb');
1678
+ if (!el) return;
1679
+ const path = state.current && state.current.folder_id ? folderPath(state.current.folder_id) : [];
1680
+ el.textContent = path.length ? path.join(' / ') + ' /' : '';
1681
+ el.title = path.length ? 'Folder: ' + path.join(' / ') : '';
1682
+ }
1683
+
1684
+ // Tree model for the current (possibly filtered) request list.
1685
+ function buildTree() {
1686
+ const known = new Set(state.folders.map((f) => f.id));
1687
+ const kids = new Map();
1688
+ state.folders.forEach((f) => {
1689
+ const p = f.parent_id && known.has(f.parent_id) ? f.parent_id : '';
1690
+ if (!kids.has(p)) kids.set(p, []);
1691
+ kids.get(p).push(f);
1692
+ });
1693
+ const reqs = new Map();
1694
+ state.requests.forEach((r) => {
1695
+ const p = r.folder_id && known.has(r.folder_id) ? r.folder_id : '';
1696
+ if (!reqs.has(p)) reqs.set(p, []);
1697
+ reqs.get(p).push(r);
1698
+ });
1699
+ const sub = new Map();
1700
+ const subtree = (fid) => {
1701
+ if (sub.has(fid)) return sub.get(fid);
1702
+ const list = [];
1703
+ (kids.get(fid) || []).forEach((f) => list.push(...subtree(f.id)));
1704
+ list.push(...(reqs.get(fid) || []));
1705
+ sub.set(fid, list);
1706
+ return list;
1707
+ };
1708
+ return { kids, reqs, subtree };
1709
+ }
1710
+
1711
+ function requestRow(r, depth) {
1712
+ const li = document.createElement('li');
1713
+ li.className = 'req-item' +
1714
+ (state.current && state.current.id === r.id ? ' active' : '') +
1715
+ (isSelected(r.id) ? ' selected' : '') +
1716
+ (state.kbId === r.id ? ' kb-focus' : '');
1717
+ li.style.setProperty('--depth', depth);
1718
+ li.draggable = true;
1719
+ li.dataset.rid = r.id;
1720
+ li.dataset.folder = r.folder_id || '';
1721
+ const tags = r.tags.map((t) => `<span class="tag">#${esc(t)}</span>`).join('');
1722
+ li.innerHTML =
1723
+ `<input type="checkbox" ${isSelected(r.id) ? 'checked' : ''} title="Select for run">` +
1724
+ `<span class="method m-${esc(r.method)}">${esc(r.method)}</span>` +
1725
+ `<div style="min-width:0"><div class="name">${esc(r.name)}${sendCount(r) > 1 ? `<span class="bcount" title="Checked bodies">×${sendCount(r)}</span>` : ''}</div><div class="sub">${tags}${esc(r.url)}</div></div>` +
1726
+ '<button class="more" title="More actions" aria-haspopup="menu">⋯</button>';
1727
+ $('.more', li).addEventListener('click', (e) => { e.stopPropagation(); openReqMenu(r, e.currentTarget); });
1728
+ li.addEventListener('contextmenu', (e) => { e.preventDefault(); openReqMenu(r, li, { x: e.clientX, y: e.clientY }); });
1729
+ $('input', li).addEventListener('click', (e) => { e.stopPropagation(); toggleSelect(r); });
1730
+ li.addEventListener('click', () => openRequest(r.id));
1731
+ return li;
1732
+ }
1733
+
1734
+ function folderRow(f, depth, list, open) {
1735
+ const li = document.createElement('li');
1736
+ li.className = 'folder-item' + (open ? ' open' : '');
1737
+ li.style.setProperty('--depth', depth);
1738
+ li.draggable = true;
1739
+ li.dataset.fid = f.id;
1740
+ const nSel = list.filter((r) => isSelected(r.id)).length;
1741
+ li.innerHTML =
1742
+ `<input type="checkbox" title="Select every request in this folder" ${list.length ? '' : 'disabled'} ${list.length && nSel === list.length ? 'checked' : ''}>` +
1743
+ `<span class="fname"><span class="caret">▶</span><svg class="ficon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 7.5A1.5 1.5 0 0 1 4.5 6H9l2 2h8.5A1.5 1.5 0 0 1 21 9.5v8a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5z"/></svg><span class="t">${esc(f.name)}</span></span>` +
1744
+ `<span class="fcount">${list.length}</span>` +
1745
+ '<button class="more" title="Folder actions" aria-haspopup="menu">⋯</button>';
1746
+ const cb = $('input', li);
1747
+ cb.indeterminate = nSel > 0 && nSel < list.length;
1748
+ cb.addEventListener('click', (e) => {
1749
+ e.stopPropagation();
1750
+ if (cb.checked) list.forEach((r) => { if (!isSelected(r.id)) state.selected.push(selEntry(r)); });
1751
+ else { const ids = new Set(list.map((r) => r.id)); state.selected = state.selected.filter((x) => !ids.has(x.id)); }
1752
+ renderList();
1753
+ });
1754
+ $('.more', li).addEventListener('click', (e) => { e.stopPropagation(); openFolderMenu(f, e.currentTarget); });
1755
+ li.addEventListener('contextmenu', (e) => { e.preventDefault(); openFolderMenu(f, li, { x: e.clientX, y: e.clientY }); });
1756
+ li.addEventListener('click', () => {
1757
+ if (state.expanded.has(f.id)) state.expanded.delete(f.id); else state.expanded.add(f.id);
1758
+ saveExpanded();
1759
+ renderList();
1760
+ });
1761
+ return li;
1762
+ }
1763
+
1764
+ function renderList() {
1765
+ const ul = $('#req-list');
1766
+ ul.innerHTML = '';
1767
+ const filtering = !!(state.q || state.tag);
1768
+ const tree = buildTree();
1769
+ state.treeOrder = [...(tree.kids.get('') || []).flatMap((f) => tree.subtree(f.id)), ...(tree.reqs.get('') || [])];
1770
+ const walk = (fid, depth) => {
1771
+ (tree.kids.get(fid) || []).forEach((f) => {
1772
+ const list = tree.subtree(f.id);
1773
+ if (filtering && !list.length) return;
1774
+ const open = filtering || state.expanded.has(f.id);
1775
+ ul.appendChild(folderRow(f, depth, list, open));
1776
+ if (open) walk(f.id, depth + 1);
1777
+ });
1778
+ (tree.reqs.get(fid) || []).forEach((r) => ul.appendChild(requestRow(r, depth)));
1779
+ };
1780
+ walk('', 0);
1781
+ if (!ul.children.length) {
1782
+ ul.innerHTML = `<li class="empty small">${filtering ? 'No requests match.' : 'No requests yet. Click “+ New”, or create a folder first.'}</li>`;
1783
+ }
1784
+ updateSelectionBar();
1785
+ renderCrumb();
1786
+ if (typeof renderReqTabs === 'function') renderReqTabs();
1787
+ }
1788
+
1789
+ // --------------------------------------------------------- generic menu
1790
+ function closeMenu() {
1791
+ const m = $('#ctx-menu');
1792
+ if (m) m.remove();
1793
+ $$('.menu-open').forEach((li) => li.classList.remove('menu-open'));
1794
+ }
1795
+ function openMenu(key, anchor, at, items) {
1796
+ const already = $('#ctx-menu') && $('#ctx-menu').dataset.key === key;
1797
+ closeMenu();
1798
+ if (already && !at) return; // a second click on ⋯ closes it
1799
+ const m = document.createElement('div');
1800
+ m.id = 'ctx-menu';
1801
+ m.className = 'menu';
1802
+ m.dataset.key = key;
1803
+ m.setAttribute('role', 'menu');
1804
+ items.forEach((it) => {
1805
+ if (it === '-') { m.insertAdjacentHTML('beforeend', '<div class="sep"></div>'); return; }
1806
+ const b = document.createElement('button');
1807
+ b.setAttribute('role', 'menuitem');
1808
+ b.dataset.act = it.act;
1809
+ if (it.danger) b.className = 'danger';
1810
+ b.innerHTML = `<span class="mi">${it.icon}</span>${esc(it.label)}`;
1811
+ b.addEventListener('click', () => { closeMenu(); it.run(); });
1812
+ m.appendChild(b);
1813
+ });
1814
+ document.body.appendChild(m);
1815
+ const row = anchor.closest('.req-item, .folder-item, .h-item');
1816
+ if (row) row.classList.add('menu-open');
1817
+ const rect = anchor.getBoundingClientRect();
1818
+ let left = at ? at.x : rect.right - m.offsetWidth;
1819
+ let top = at ? at.y : rect.bottom + 4;
1820
+ if (top + m.offsetHeight > window.innerHeight - 8) top = (at ? at.y : rect.top - 4) - m.offsetHeight;
1821
+ left = Math.max(8, Math.min(left, window.innerWidth - m.offsetWidth - 8));
1822
+ m.style.left = left + 'px';
1823
+ m.style.top = Math.max(8, top) + 'px';
1824
+ $('button', m).focus();
1825
+ m.addEventListener('keydown', (e) => {
1826
+ const bs = $$('button', m);
1827
+ const i = bs.indexOf(document.activeElement);
1828
+ if (e.key === 'ArrowDown') { e.preventDefault(); bs[(i + 1) % bs.length].focus(); }
1829
+ if (e.key === 'ArrowUp') { e.preventDefault(); bs[(i - 1 + bs.length) % bs.length].focus(); }
1830
+ });
1831
+ }
1832
+ function openReqMenu(r, anchor, at) {
1833
+ openMenu('r:' + r.id, anchor, at, [
1834
+ { act: 'dup', icon: '⧉', label: 'Duplicate', run: () => duplicateRequestById(r.id) },
1835
+ { act: 'move', icon: '↪', label: 'Move to folder…', run: () => moveRequestDialog(r) },
1836
+ '-',
1837
+ { act: 'del', icon: '🗑', label: 'Delete', danger: true, run: () => deleteRequestById(r.id, r.name) },
1838
+ ]);
1839
+ }
1840
+ function openFolderMenu(f, anchor, at) {
1841
+ openMenu('f:' + f.id, anchor, at, [
1842
+ { act: 'add-req', icon: '+', label: 'Add request', run: () => newRequest(f.id) },
1843
+ { act: 'add-folder', icon: '📁', label: 'Add folder', run: () => createFolder(f.id) },
1844
+ { act: 'run', icon: '▶', label: 'Run folder', run: () => runFolder(f) },
1845
+ '-',
1846
+ { act: 'settings', icon: '⚙', label: 'Settings (auth, headers, variables)…', run: () => openFolderSettings(f) },
1847
+ { act: 'export', icon: '🚀', label: 'Export as Postman collection', run: () => exportPostman(false, f.id) },
1848
+ '-',
1849
+ { act: 'rename', icon: '✎', label: 'Rename', run: () => renameFolder(f) },
1850
+ { act: 'move', icon: '↪', label: 'Move to…', run: () => moveFolderDialog(f) },
1851
+ { act: 'del', icon: '🗑', label: 'Delete', danger: true, run: () => deleteFolder(f) },
1852
+ ]);
1853
+ }
1854
+
1855
+ // ------------------------------------------------------- folder actions
1856
+ async function createFolder(parentId = null) {
1857
+ const where = parentId ? ` inside ${strong(folderPath(parentId).join(' / '))}` : '';
1858
+ const name = await promptDialog('New folder', { label: 'Folder name', placeholder: 'e.g. QA, Prod, Users', okLabel: 'Create', message: where ? `The folder is created${where}.` : '' });
1859
+ if (!name) return;
1860
+ try {
1861
+ const f = await api('/folders', { method: 'POST', body: { name, parent_id: parentId } });
1862
+ if (parentId) expandTo(parentId);
1863
+ state.expanded.add(f.id);
1864
+ saveExpanded();
1865
+ await loadRequests();
1866
+ toast(`Folder “${f.name}” created`, 'ok');
1867
+ } catch (e) { toast(e.message, 'error'); }
1868
+ }
1869
+ async function renameFolder(f) {
1870
+ const name = await promptDialog('Rename folder', { label: 'Folder name', value: f.name, okLabel: 'Rename' });
1871
+ if (!name || name === f.name) return;
1872
+ try { await api('/folders/' + f.id, { method: 'PATCH', body: { name } }); await loadRequests(); }
1873
+ catch (e) { toast(e.message, 'error'); }
1874
+ }
1875
+ async function deleteFolder(f) {
1876
+ let c = { folders: 0, requests: 0 };
1877
+ try { c = await api(`/folders/${f.id}/contents`); } catch { /* counts are informational */ }
1878
+ const parts = [];
1879
+ if (c.requests) parts.push(`${c.requests} request${c.requests === 1 ? '' : 's'}`);
1880
+ if (c.folders) parts.push(`${c.folders} subfolder${c.folders === 1 ? '' : 's'}`);
1881
+ const msg = parts.length
1882
+ ? `This deletes ${strong(f.name)} and everything inside it: ${parts.join(' and ')}. This cannot be undone.`
1883
+ : `The empty folder ${strong(f.name)} will be removed.`;
1884
+ if (!await confirmDialog('Delete folder?', msg, { okLabel: 'Delete folder', danger: true })) return;
1885
+ const doomed = folderDescendants(f.id);
1886
+ if (state.current && doomed.has(state.current.folder_id)) {
1887
+ clearTimeout(autosave.timer); clearTimeout(autosave.retry); autosave.dirty = false;
1888
+ while (autosave.inFlight) await autosave.inFlight;
1889
+ }
1890
+ try {
1891
+ const out = await api('/folders/' + f.id, { method: 'DELETE' });
1892
+ const gone = new Set(out.requests);
1893
+ state.selected = state.selected.filter((x) => !gone.has(x.id));
1894
+ if (state.current && gone.has(state.current.id)) { state.current = null; renderEditor(); }
1895
+ toast(`Folder “${f.name}” deleted`);
1896
+ await loadRequests();
1897
+ } catch (e) { toast(e.message, 'error'); }
1898
+ }
1899
+ async function moveRequestTo(rid, folderId) {
1900
+ await saveNow();
1901
+ const r = await api(`/requests/${rid}/move`, { method: 'POST', body: { folder_id: folderId || null } });
1902
+ if (state.current && state.current.id === rid) state.current.folder_id = r.folder_id;
1903
+ if (folderId) expandTo(folderId);
1904
+ await loadRequests();
1905
+ return r;
1906
+ }
1907
+ async function moveFolderTo(fid, parentId) {
1908
+ await api('/folders/' + fid, { method: 'PATCH', body: { parent_id: parentId || null, move: true } });
1909
+ if (parentId) expandTo(parentId);
1910
+ await loadRequests();
1911
+ }
1912
+ async function moveRequestDialog(r) {
1913
+ const to = await selectDialog('Move request', { label: `Move “${r.name}” to`, options: folderOptions(), value: r.folder_id || '', okLabel: 'Move' });
1914
+ if (to === null) return;
1915
+ try { await moveRequestTo(r.id, to); toast('Moved', 'ok'); } catch (e) { toast(e.message, 'error'); }
1916
+ }
1917
+ async function moveFolderDialog(f) {
1918
+ const to = await selectDialog('Move folder', { label: `Move “${f.name}” into`, options: folderOptions(folderDescendants(f.id)), value: f.parent_id || '', okLabel: 'Move' });
1919
+ if (to === null) return;
1920
+ try { await moveFolderTo(f.id, to); toast('Moved', 'ok'); } catch (e) { toast(e.message, 'error'); }
1921
+ }
1922
+ function runFolder(f) {
1923
+ const list = buildTree().subtree(f.id);
1924
+ if (!list.length) { toast(`Folder “${f.name}” has no requests to run`, 'error'); return; }
1925
+ openRunModal({ items: list.map(selEntry), name: f.name, title: `Run folder “${f.name}”` });
1926
+ }
1927
+
1928
+ // --------------------------------------------------------- drag and drop
1929
+ function dropTargetFor(el) {
1930
+ const folderLi = el.closest('.folder-item');
1931
+ if (folderLi) return { folderId: folderLi.dataset.fid, el: folderLi };
1932
+ const reqLi = el.closest('.req-item');
1933
+ if (reqLi && reqLi.dataset.folder) {
1934
+ return { folderId: reqLi.dataset.folder, el: $(`.folder-item[data-fid="${reqLi.dataset.folder}"]`) };
1935
+ }
1936
+ return { folderId: '', el: null };
1937
+ }
1938
+ function canDrop(target) {
1939
+ const d = state.drag;
1940
+ if (!d) return false;
1941
+ if (d.type === 'folder') return !folderDescendants(d.id).has(target.folderId) && (folderById(d.id).parent_id || '') !== target.folderId;
1942
+ const r = state.requests.find((x) => x.id === d.id);
1943
+ return !!r && (r.folder_id || '') !== target.folderId;
1944
+ }
1945
+ function clearDropMarks() {
1946
+ $$('.drop-target').forEach((x) => x.classList.remove('drop-target'));
1947
+ $('#req-list').classList.remove('drop-root');
1948
+ }
1949
+ function bindDragAndDrop() {
1950
+ const ul = $('#req-list');
1951
+ ul.addEventListener('dragstart', (e) => {
1952
+ const li = e.target.closest('.req-item, .folder-item');
1953
+ if (!li) return;
1954
+ state.drag = li.dataset.rid ? { type: 'request', id: li.dataset.rid } : { type: 'folder', id: li.dataset.fid };
1955
+ e.dataTransfer.effectAllowed = 'move';
1956
+ e.dataTransfer.setData('text/plain', state.drag.id);
1957
+ li.classList.add('dragging');
1958
+ closeMenu();
1959
+ });
1960
+ ul.addEventListener('dragend', () => {
1961
+ state.drag = null;
1962
+ clearDropMarks();
1963
+ $$('.dragging').forEach((x) => x.classList.remove('dragging'));
1964
+ });
1965
+ ul.addEventListener('dragover', (e) => {
1966
+ if (!state.drag) return;
1967
+ const t = dropTargetFor(e.target);
1968
+ clearDropMarks();
1969
+ if (!canDrop(t)) return;
1970
+ e.preventDefault();
1971
+ e.dataTransfer.dropEffect = 'move';
1972
+ if (t.el) t.el.classList.add('drop-target'); else ul.classList.add('drop-root');
1973
+ });
1974
+ ul.addEventListener('dragleave', (e) => { if (!ul.contains(e.relatedTarget)) clearDropMarks(); });
1975
+ ul.addEventListener('drop', async (e) => {
1976
+ if (!state.drag) return;
1977
+ e.preventDefault();
1978
+ const t = dropTargetFor(e.target);
1979
+ const ok = canDrop(t);
1980
+ const d = state.drag;
1981
+ state.drag = null;
1982
+ clearDropMarks();
1983
+ if (!ok) return;
1984
+ try {
1985
+ if (d.type === 'request') await moveRequestTo(d.id, t.folderId);
1986
+ else await moveFolderTo(d.id, t.folderId);
1987
+ toast(t.folderId ? `Moved to “${folderPath(t.folderId).join(' / ')}”` : 'Moved to top level', 'ok');
1988
+ } catch (err) { toast(err.message, 'error'); }
1989
+ });
1990
+ }
1991
+
1992
+ // =========================================================== environments
1993
+ const ENV_PRESETS = [
1994
+ { name: 'Dev', base_url: 'https://dev.api.example.com' },
1995
+ { name: 'QA', base_url: 'https://qa.api.example.com' },
1996
+ { name: 'Staging', base_url: 'https://staging.api.example.com' },
1997
+ { name: 'Prod', base_url: 'https://api.example.com' },
1998
+ ];
1999
+ async function loadEnvironments() {
2000
+ const data = await api('/environments');
2001
+ state.envs = data.environments;
2002
+ state.activeEnvId = data.active_id;
2003
+ renderEnvSelect();
2004
+ return data;
2005
+ }
2006
+ function renderEnvSelect() {
2007
+ setTimeout(updateStatusbar, 0);
2008
+ const sel = $('#env-select');
2009
+ const active = state.envs.find((e) => e.id === state.activeEnvId);
2010
+ sel.innerHTML = '<option value="">No environment</option>' +
2011
+ state.envs.map((e) => `<option value="${e.id}">${esc(e.name)}</option>`).join('') +
2012
+ '<option disabled>──────────</option><option value="__new">+ New environment…</option><option value="__manage">Manage environments…</option>';
2013
+ sel.value = active ? active.id : '';
2014
+ const picker = sel.closest('.env-picker');
2015
+ picker.classList.toggle('on', !!active);
2016
+ picker.classList.toggle('prod', !!active && /prod/i.test(active.name));
2017
+ }
2018
+ async function setActiveEnv(id) {
2019
+ try {
2020
+ const data = await api('/environments/active', { method: 'PUT', body: { id: id || null } });
2021
+ state.envs = data.environments;
2022
+ state.activeEnvId = data.active_id;
2023
+ renderEnvSelect();
2024
+ const env = state.envs.find((e) => e.id === id);
2025
+ toast(env ? `Environment: ${env.name}` : 'No environment', 'ok');
2026
+ } catch (e) { toast(e.message, 'error'); }
2027
+ }
2028
+
2029
+ // Environment manager (modal). Edits save automatically.
2030
+ const envSave = { timer: null, id: null };
2031
+ async function flushEnvSave() {
2032
+ if (!envSave.timer) return;
2033
+ clearTimeout(envSave.timer);
2034
+ envSave.timer = null;
2035
+ await saveEnvEditor();
2036
+ }
2037
+ async function saveEnvEditor() {
2038
+ const id = envSave.id;
2039
+ const box = $('#env-editor');
2040
+ if (!id || !box) return;
2041
+ const name = $('#env-name', box).value.trim();
2042
+ const variables = readKv($('#kv-env', box));
2043
+ try {
2044
+ const env = await api('/environments/' + id, { method: 'PATCH', body: { name: name || undefined, variables } });
2045
+ const i = state.envs.findIndex((e) => e.id === id);
2046
+ if (i >= 0) state.envs[i] = env;
2047
+ renderEnvSelect();
2048
+ const label = $(`.env-item[data-id="${id}"] .en`);
2049
+ if (label) label.textContent = env.name;
2050
+ const st = $('#env-save-state');
2051
+ if (st) { st.className = 'save-state saved'; st.textContent = 'Saved'; }
2052
+ } catch (e) { toast('Could not save environment: ' + e.message, 'error'); }
2053
+ }
2054
+ function scheduleEnvSave() {
2055
+ const st = $('#env-save-state');
2056
+ if (st) { st.className = 'save-state saving'; st.textContent = 'Editing…'; }
2057
+ clearTimeout(envSave.timer);
2058
+ envSave.timer = setTimeout(() => { envSave.timer = null; saveEnvEditor(); }, 500);
2059
+ }
2060
+ async function openEnvManager(focusId = null, createNew = false) {
2061
+ await loadEnvironments();
2062
+ openModal('Environments', '<div id="env-manager" class="env-manager"></div>', [
2063
+ { label: 'Done', cls: 'primary', onClick: async () => { await flushEnvSave(); closeModal(); } },
2064
+ ]);
2065
+ envSave.id = focusId || state.activeEnvId || (state.envs[0] || {}).id || null;
2066
+ renderEnvManager();
2067
+ if (createNew) createEnvironment();
2068
+ }
2069
+ function renderEnvManager() {
2070
+ const root = $('#env-manager');
2071
+ if (!root) return;
2072
+ const existing = new Set(state.envs.map((e) => e.name.toLowerCase()));
2073
+ const quick = ENV_PRESETS.filter((p) => !existing.has(p.name.toLowerCase()));
2074
+ root.innerHTML = `
2075
+ <div class="env-list">
2076
+ ${state.envs.map((e) => `<div class="env-item ${e.id === envSave.id ? 'active-edit' : ''}" data-id="${e.id}"><span class="en">${esc(e.name)}</span>${e.id === state.activeEnvId ? '<span class="live">active</span>' : ''}</div>`).join('') || '<div class="muted small">No environments yet.</div>'}
2077
+ <button id="env-add" class="link" style="text-align:left">+ New environment</button>
2078
+ ${quick.length ? `<div class="muted small" style="margin-top:8px">Quick add</div><div class="env-quick">${quick.map((p) => `<span class="chip" data-preset="${esc(p.name)}">+ ${esc(p.name)}</span>`).join('')}</div>` : ''}
2079
+ </div>
2080
+ <div id="env-editor"></div>`;
2081
+ $$('.env-item', root).forEach((el) => el.addEventListener('click', async () => {
2082
+ await flushEnvSave();
2083
+ envSave.id = el.dataset.id;
2084
+ renderEnvManager();
2085
+ }));
2086
+ $('#env-add', root).addEventListener('click', () => createEnvironment());
2087
+ $$('[data-preset]', root).forEach((el) => el.addEventListener('click', () => {
2088
+ const p = ENV_PRESETS.find((x) => x.name === el.dataset.preset);
2089
+ createEnvironment(p.name, [{ key: 'base_url', value: p.base_url, enabled: true }]);
2090
+ }));
2091
+ renderEnvEditor();
2092
+ }
2093
+ function renderEnvEditor() {
2094
+ const box = $('#env-editor');
2095
+ const env = state.envs.find((e) => e.id === envSave.id);
2096
+ if (!env) {
2097
+ box.innerHTML = `<div class="empty"><p>Create an environment such as <strong>Dev</strong>, <strong>QA</strong> or <strong>Prod</strong>.</p>
2098
+ <p class="muted small">Each environment has its own variables. Use them as <code>{{base_url}}</code> and switch environments from the top bar.</p></div>`;
2099
+ return;
2100
+ }
2101
+ const isActive = env.id === state.activeEnvId;
2102
+ box.innerHTML = `
2103
+ <div class="env-edit-head">
2104
+ <input id="env-name" value="${esc(env.name)}" placeholder="Environment name" spellcheck="false">
2105
+ <button id="env-activate" class="${isActive ? '' : 'accent'}">${isActive ? '✓ Active' : 'Set active'}</button>
2106
+ <button id="env-dup" class="icon-btn" title="Duplicate environment">⧉</button>
2107
+ <button id="env-del" class="icon-btn danger" title="Delete environment">🗑</button>
2108
+ </div>
2109
+ <p class="muted small env-note">These values override global variables with the same name while this environment is active. Unchecked rows are ignored. 🔒 marks a secret: masked on screen and in saved history and run files. <span id="env-save-state" class="save-state saved">Saved</span></p>
2110
+ <div id="kv-env" class="kv"></div>`;
2111
+ $('#kv-env', box).dataset.secrets = '1';
2112
+ renderKv($('#kv-env', box), env.variables, true);
2113
+ box.addEventListener('input', scheduleEnvSave);
2114
+ box.addEventListener('change', scheduleEnvSave);
2115
+ box.addEventListener('click', (e) => { if (e.target.closest('.kv-del, .kv-secret')) scheduleEnvSave(); });
2116
+ $('#env-activate', box).addEventListener('click', async () => {
2117
+ await flushEnvSave();
2118
+ await setActiveEnv(isActive ? null : env.id);
2119
+ renderEnvManager();
2120
+ });
2121
+ $('#env-dup', box).addEventListener('click', async () => {
2122
+ await flushEnvSave();
2123
+ try {
2124
+ const copy = await api(`/environments/${env.id}/duplicate`, { method: 'POST' });
2125
+ await loadEnvironments();
2126
+ envSave.id = copy.id;
2127
+ renderEnvManager();
2128
+ $('#env-name').select();
2129
+ } catch (e) { toast(e.message, 'error'); }
2130
+ });
2131
+ $('#env-del', box).addEventListener('click', async () => {
2132
+ if (!await confirmDialog('Delete environment?', `${strong(env.name)} and its ${env.variables.length} variable(s) will be deleted.`, { okLabel: 'Delete', danger: true })) return;
2133
+ clearTimeout(envSave.timer); envSave.timer = null;
2134
+ try {
2135
+ await api('/environments/' + env.id, { method: 'DELETE' });
2136
+ await loadEnvironments();
2137
+ envSave.id = (state.envs[0] || {}).id || null;
2138
+ renderEnvManager();
2139
+ } catch (e) { toast(e.message, 'error'); }
2140
+ });
2141
+ }
2142
+ async function createEnvironment(name = null, variables = null) {
2143
+ await flushEnvSave();
2144
+ if (!name) {
2145
+ name = await promptDialog('New environment', { label: 'Name', placeholder: 'e.g. Dev, QA, Staging, Prod', okLabel: 'Create' });
2146
+ if (!name) return;
2147
+ variables = [{ key: 'base_url', value: '', enabled: true }];
2148
+ }
2149
+ try {
2150
+ const env = await api('/environments', { method: 'POST', body: { name, variables } });
2151
+ await loadEnvironments();
2152
+ envSave.id = env.id;
2153
+ if (!$('#env-manager') || $('#modal').hidden) await openEnvManager(env.id); else renderEnvManager();
2154
+ const firstVal = $('#kv-env .kv-val');
2155
+ if (firstVal) firstVal.focus();
2156
+ } catch (e) { toast(e.message, 'error'); }
2157
+ }
2158
+
2159
+ // ================================================================ console
2160
+ // Every request the backend sends (Send, multi-body Send, runs) is logged on the
2161
+ // server; the console polls for new entries and shows full details per request.
2162
+ const con = { open: false, lastSeq: 0, entries: [], timer: null, expanded: new Set(), q: '', errorsOnly: false };
2163
+ const CON_MAX = 500;
2164
+ const conIsError = (e) => e.status === 'error' || (e.status_code || 0) >= 400;
2165
+ const codeClass = (c) => (c == null ? 'err' : c < 300 ? 'ok' : c < 400 ? 'warn' : 'err');
2166
+ function fmtClock(ts) {
2167
+ const d = new Date(ts * 1000);
2168
+ return d.toLocaleTimeString([], { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0');
2169
+ }
2170
+
2171
+ async function pollConsole() {
2172
+ clearTimeout(con.timer);
2173
+ try {
2174
+ const d = await api('/console?after=' + con.lastSeq);
2175
+ if (d.last_seq < con.lastSeq) { con.entries = []; con.lastSeq = 0; } // backend restarted
2176
+ if (d.entries.length) {
2177
+ con.entries.push(...d.entries);
2178
+ if (con.entries.length > CON_MAX) con.entries.splice(0, con.entries.length - CON_MAX);
2179
+ con.lastSeq = d.last_seq;
2180
+ if (con.open) renderConsole();
2181
+ updateStatusbar();
2182
+ }
2183
+ } catch { /* backend busy or restarting; try again */ }
2184
+ con.timer = setTimeout(pollConsole, con.open ? 700 : 2500);
2185
+ }
2186
+ function updateStatusbar() {
2187
+ const n = con.entries.length;
2188
+ const errs = con.entries.filter(conIsError).length;
2189
+ $('#console-count').textContent = n ? String(n) : '';
2190
+ const ec = $('#console-errcount');
2191
+ ec.hidden = !errs;
2192
+ ec.textContent = errs ? `${errs} failed` : '';
2193
+ ec.title = errs ? `${errs} request(s) failed or returned 4xx/5xx` : '';
2194
+ const last = con.entries[n - 1];
2195
+ $('#sb-last').innerHTML = last
2196
+ ? `Last: ${esc(last.method)} <span class="${codeClass(last.status_code)}">${last.status_code ?? 'ERR'}</span> · ${fmtMs(last.duration_ms)} · ${esc(last.url)}`
2197
+ : '';
2198
+ const env = state.envs ? state.envs.find((e) => e.id === state.activeEnvId) : null;
2199
+ $('#sb-env').textContent = env ? `Environment: ${env.name}` : 'No environment';
2200
+ }
2201
+
2202
+ function conMatches(e) {
2203
+ if (con.errorsOnly && !conIsError(e)) return false;
2204
+ if (!con.q) return true;
2205
+ const hay = `${e.method} ${e.url} ${e.status_code ?? 'error'} ${e.reason} ${e.request_name} ${e.body_name || ''} ${e.source} ${e.error || ''}`.toLowerCase();
2206
+ return con.q.toLowerCase().split(/\s+/).filter(Boolean).every((t) => hay.includes(t));
2207
+ }
2208
+ function kvTable(rows) {
2209
+ if (!rows || !rows.length) return '<div class="muted small" style="padding:2px 0 6px 14px">none</div>';
2210
+ return `<div class="c-kv">${rows.map(([k, v]) => `<span class="k">${esc(k)}</span><span class="v">${esc(v)}</span>`).join('')}</div>`;
2211
+ }
2212
+ function conBody(text, isJson, cut, seq, kind) {
2213
+ if (!text) return '<div class="muted small" style="padding:2px 0 6px 14px">empty</div>';
2214
+ if (kind) return `<div class="c-viewer" data-seq="${seq}" data-kind="${kind}"></div>`;
2215
+ let out = text;
2216
+ if (isJson !== false) { try { out = JSON.stringify(JSON.parse(text), null, 2); } catch { /* not JSON */ } }
2217
+ return `<pre class="mono">${esc(out)}${cut ? '\n… [truncated in console]' : ''}</pre>`;
2218
+ }
2219
+ function mountConsoleViewers(list) {
2220
+ $$('.c-viewer', list).forEach((el) => {
2221
+ const e = con.entries.find((x) => x.seq === Number(el.dataset.seq));
2222
+ if (!e) return;
2223
+ const r = el.dataset.kind === 'resp' ? e.response : null;
2224
+ const resp = r
2225
+ ? { body_text: r.body, is_json: r.is_json, content_type: r.content_type, truncated: r.body_truncated, size: r.size, is_binary: false }
2226
+ : { body_text: e.request.body, is_json: undefined, content_type: (e.request.headers.find(([k]) => k.toLowerCase() === 'content-type') || [])[1] || '', truncated: e.request.body_truncated, is_binary: false };
2227
+ mountViewer(el, resp, { wrap: true });
2228
+ });
2229
+ }
2230
+ function conDetail(e) {
2231
+ const r = e.response;
2232
+ const general = [
2233
+ ['Request URL', e.url], ['Method', e.method],
2234
+ ['Status', e.status_code != null ? `${e.status_code} ${e.reason}` : 'no response'],
2235
+ ['Duration', fmtMs(e.duration_ms)],
2236
+ ];
2237
+ if (r) {
2238
+ general.push(['Response size', fmtBytes(r.size)], ['Content type', r.content_type || '—'], ['HTTP version', r.http_version || '—']);
2239
+ if (r.redirects && r.redirects.length) general.push(['Redirected from', r.redirects.join(' → ')]);
2240
+ }
2241
+ general.push(['Environment', e.environment || 'none'], ['Source', e.source],
2242
+ ['Request', e.request_name + (e.body_name ? ` · body “${e.body_name}”` : '')], ['Sent at', new Date(e.ts * 1000).toLocaleString()]);
2243
+ return `<div class="c-detail">
2244
+ ${e.error ? `<div class="c-err">✕ ${esc(e.error)}</div>` : ''}
2245
+ <details open><summary>General</summary>${kvTable(general)}</details>
2246
+ <details open><summary>Request headers (${e.request.headers.length})</summary>${kvTable(e.request.headers)}</details>
2247
+ ${e.request.body ? `<details open><summary>Request body</summary>${conBody(e.request.body, undefined, e.request.body_truncated, e.seq, 'req')}</details>` : ''}
2248
+ ${r ? `<details open><summary>Response headers (${r.headers.length})</summary>${kvTable(r.headers)}</details>
2249
+ <details open><summary>Response body</summary>${conBody(r.body, r.is_json, r.body_truncated, e.seq, 'resp')}</details>` : ''}
2250
+ </div>`;
2251
+ }
2252
+ function renderConsole() {
2253
+ const list = $('#console-list');
2254
+ const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 30;
2255
+ const shown = con.entries.filter(conMatches);
2256
+ const errs = con.entries.filter(conIsError).length;
2257
+ $('#console-info').textContent = con.entries.length
2258
+ ? `${shown.length === con.entries.length ? '' : `${shown.length} of `}${con.entries.length} request(s)${errs ? `, ${errs} failed` : ''}`
2259
+ : '';
2260
+ $('#console-expand').textContent = shown.length && shown.every((e) => con.expanded.has(e.seq)) ? 'Collapse all' : 'Expand all';
2261
+ if (!shown.length) {
2262
+ list.innerHTML = `<div class="c-empty">${con.entries.length ? 'No entries match the filter.' : 'No requests yet. Everything you send, including runs, appears here.'}</div>`;
2263
+ return;
2264
+ }
2265
+ list.innerHTML = shown.map((e) => {
2266
+ const open = con.expanded.has(e.seq);
2267
+ const label = e.request_name + (e.body_name ? ` · ${e.body_name}` : '');
2268
+ return `<div class="c-row ${open ? 'open' : ''} ${conIsError(e) ? 'err' : ''}" data-seq="${e.seq}" title="${esc(label)} (${esc(e.source)})">
2269
+ <span class="caret">▶</span>
2270
+ <span class="ts">${fmtClock(e.ts)}</span>
2271
+ <span class="src">${esc(e.source)}</span>
2272
+ <span class="method m-${esc(e.method)}">${esc(e.method)}</span>
2273
+ <span class="code ${codeClass(e.status_code)}">${e.status_code ?? 'ERROR'}</span>
2274
+ <span class="url">${esc(e.url)}</span>
2275
+ <span class="dur">${fmtMs(e.duration_ms)}</span>
2276
+ <span class="size">${e.response ? fmtBytes(e.response.size) : '—'}</span>
2277
+ </div>${open ? conDetail(e) : ''}`;
2278
+ }).join('');
2279
+ mountConsoleViewers(list);
2280
+ if (atBottom) list.scrollTop = list.scrollHeight;
2281
+ }
2282
+ function setConsoleOpen(open) {
2283
+ con.open = open;
2284
+ $('#console').hidden = !open;
2285
+ $('#btn-console').classList.toggle('on', open);
2286
+ try { localStorage.setItem('pyhttp.console', open ? '1' : '0'); } catch { /* ignore */ }
2287
+ if (open) {
2288
+ renderConsole();
2289
+ const list = $('#console-list');
2290
+ list.scrollTop = list.scrollHeight;
2291
+ pollConsole();
2292
+ }
2293
+ }
2294
+ function bindConsole() {
2295
+ $('#btn-console').addEventListener('click', () => setConsoleOpen(!con.open));
2296
+ $('#console-close').addEventListener('click', () => setConsoleOpen(false));
2297
+ $('#console-list').addEventListener('click', (e) => {
2298
+ const row = e.target.closest('.c-row');
2299
+ if (!row) return;
2300
+ const seq = Number(row.dataset.seq);
2301
+ if (con.expanded.has(seq)) con.expanded.delete(seq); else con.expanded.add(seq);
2302
+ const list = $('#console-list');
2303
+ const keep = list.scrollTop;
2304
+ renderConsole();
2305
+ list.scrollTop = keep;
2306
+ });
2307
+ let ft = null;
2308
+ $('#console-filter').addEventListener('input', (e) => {
2309
+ clearTimeout(ft);
2310
+ ft = setTimeout(() => { con.q = e.target.value.trim(); renderConsole(); }, 120);
2311
+ });
2312
+ $('#console-errors').addEventListener('change', (e) => { con.errorsOnly = e.target.checked; renderConsole(); });
2313
+ $('#console-expand').addEventListener('click', () => {
2314
+ const shown = con.entries.filter(conMatches);
2315
+ const all = shown.length && shown.every((x) => con.expanded.has(x.seq));
2316
+ shown.forEach((x) => (all ? con.expanded.delete(x.seq) : con.expanded.add(x.seq)));
2317
+ renderConsole();
2318
+ });
2319
+ $('#console-clear').addEventListener('click', async () => {
2320
+ try { await api('/console', { method: 'DELETE' }); } catch (err) { toast(err.message, 'error'); return; }
2321
+ con.entries = [];
2322
+ con.expanded.clear();
2323
+ renderConsole();
2324
+ updateStatusbar();
2325
+ });
2326
+ // drag the top edge to resize
2327
+ const panel = $('#console');
2328
+ $('#console-resize').addEventListener('mousedown', (e) => {
2329
+ e.preventDefault();
2330
+ const startY = e.clientY;
2331
+ const startH = panel.getBoundingClientRect().height;
2332
+ panel.classList.add('resizing');
2333
+ const move = (ev) => {
2334
+ const h = Math.max(120, Math.min(window.innerHeight * 0.8, startH + (startY - ev.clientY)));
2335
+ panel.style.setProperty('--console-h', h + 'px');
2336
+ };
2337
+ const up = () => {
2338
+ panel.classList.remove('resizing');
2339
+ document.removeEventListener('mousemove', move);
2340
+ document.removeEventListener('mouseup', up);
2341
+ try { localStorage.setItem('pyhttp.console.h', panel.style.getPropertyValue('--console-h')); } catch { /* ignore */ }
2342
+ };
2343
+ document.addEventListener('mousemove', move);
2344
+ document.addEventListener('mouseup', up);
2345
+ });
2346
+ try {
2347
+ const h = localStorage.getItem('pyhttp.console.h');
2348
+ if (h) panel.style.setProperty('--console-h', h);
2349
+ if (localStorage.getItem('pyhttp.console') === '1') setConsoleOpen(true);
2350
+ } catch { /* ignore */ }
2351
+ document.addEventListener('keydown', (e) => {
2352
+ if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'c') { e.preventDefault(); setConsoleOpen(!con.open); }
2353
+ });
2354
+ pollConsole();
2355
+ }
2356
+
2357
+ // ----------------------------------------------------------- copy response
2358
+ async function copyText(text) {
2359
+ try { await navigator.clipboard.writeText(text); return true; } catch { /* fall back below */ }
2360
+ const ta = document.createElement('textarea');
2361
+ ta.value = text;
2362
+ ta.style.position = 'fixed';
2363
+ ta.style.opacity = '0';
2364
+ document.body.appendChild(ta);
2365
+ ta.select();
2366
+ let ok = false;
2367
+ try { ok = document.execCommand('copy'); } catch { ok = false; }
2368
+ ta.remove();
2369
+ return ok;
2370
+ }
2371
+ function currentRespView() {
2372
+ const active = $('#resp-tabs button.active');
2373
+ return active ? active.dataset.tab : 'body';
2374
+ }
2375
+ function updateCopyButton() {
2376
+ const view = currentRespView();
2377
+ const label = { body: 'response body', headers: 'response headers', request: 'request as sent', cookies: 'cookies', tests: 'test results' }[view] || 'response';
2378
+ $('#r-copy').title = `Copy the ${label}`;
2379
+ $('#r-save').hidden = view !== 'body';
2380
+ }
2381
+ function responseTextFor(view) {
2382
+ const r = lastResult;
2383
+ if (!r) return '';
2384
+ const resp = r.response;
2385
+ if (view === 'body') return resp ? (resp.body_text || '') : (r.error || '');
2386
+ if (view === 'headers') return resp ? resp.headers.map(([k, v]) => `${k}: ${v}`).join('\n') : '';
2387
+ if (view === 'cookies') return resp ? (resp.cookies || []).map((c) => `${c.name}=${c.value}; Domain=${c.domain}; Path=${c.path}`).join('\n') : '';
2388
+ if (view === 'tests') return (r.tests || []).map((t) => `${t.passed ? 'PASS' : 'FAIL'} ${t.name}${t.note ? ' (' + t.note + ')' : ''}`).join('\n');
2389
+ if (view === 'request') return formatRequest(r.request);
2390
+ return '';
2391
+ }
2392
+ async function copyResponse() {
2393
+ const view = currentRespView();
2394
+ const text = responseTextFor(view);
2395
+ const btn = $('#r-copy');
2396
+ if (!text) { toast('Nothing to copy', 'error'); return; }
2397
+ if (await copyText(text)) {
2398
+ btn.classList.add('done');
2399
+ $('span', btn).textContent = 'Copied ✓';
2400
+ clearTimeout(btn._t);
2401
+ btn._t = setTimeout(() => { btn.classList.remove('done'); $('span', btn).textContent = 'Copy'; }, 1500);
2402
+ } else toast('Could not copy to the clipboard', 'error');
2403
+ }
2404
+
2405
+ // ============================================================== settings
2406
+ const appSettings = { theme: 'dark', verify_ssl: true };
2407
+ const darkQuery = window.matchMedia('(prefers-color-scheme: light)');
2408
+ function applyTheme(theme) {
2409
+ const t = theme === 'system' ? (darkQuery.matches ? 'light' : 'dark') : theme;
2410
+ document.documentElement.setAttribute('data-theme', t);
2411
+ try { localStorage.setItem('pyhttp.theme', theme); } catch { /* ignore */ }
2412
+ }
2413
+ darkQuery.addEventListener('change', () => { if (appSettings.theme === 'system') applyTheme('system'); });
2414
+ function renderSslWarning() {
2415
+ $('#sb-ssl').hidden = appSettings.verify_ssl;
2416
+ $('#sb-proxy').hidden = !(appSettings.proxy_url || '').trim();
2417
+ }
2418
+ function applyAppSettings() {
2419
+ applyTheme(appSettings.theme);
2420
+ renderSslWarning();
2421
+ applyLayout(appSettings.layout);
2422
+ applyFontSize(appSettings.editor_font_size);
2423
+ applyFonts(appSettings.ui_font, appSettings.mono_font);
2424
+ }
2425
+ async function loadSettings() {
2426
+ try {
2427
+ Object.assign(appSettings, await api('/settings'));
2428
+ applyAppSettings();
2429
+ } catch { /* keep defaults */ }
2430
+ }
2431
+ async function saveSettings(patch) {
2432
+ try {
2433
+ Object.assign(appSettings, await api('/settings', { method: 'PUT', body: patch }));
2434
+ applyAppSettings();
2435
+ } catch (e) { toast('Could not save settings: ' + e.message, 'error'); }
2436
+ }
2437
+ function openSettings() {
2438
+ const themes = [['dark', '☾', 'Dark'], ['light', '☀', 'Light'], ['system', '◐', 'System']];
2439
+ const s = appSettings;
2440
+ const sw = (id, label, desc, on) => `<label class="switch-row"><span class="txt"><b>${label}</b><span class="muted small">${desc}</span></span><span class="switch"><input type="checkbox" id="${id}" ${on ? 'checked' : ''}><span></span></span></label>`;
2441
+ // a labelled field: the label above the control, an optional hint below (keeps every tab on one grid)
2442
+ const fld = (label, control, hint = '', cls = '') => `<div class="set-field ${cls}"><span class="set-label">${label}</span>${control}${hint ? `<span class="set-hint muted small">${hint}</span>` : ''}</div>`;
2443
+ openModal('Settings', `
2444
+ <div class="set-tabs tabs small" id="set-tabs">
2445
+ <button data-tab="general" class="active">General</button><button data-tab="network" >Network</button><button data-tab="certs">Certificates</button><button data-tab="ai">AI (MCP)</button><button data-tab="about">About</button>
2446
+ </div>
2447
+ <div class="tab-panels" id="set-panels">
2448
+ <div class="tab-panel" data-panel="general">
2449
+ <div class="set-section">
2450
+ <h4>Appearance</h4>
2451
+ <div class="set-grid">
2452
+ ${fld('Theme', `<div class="seg" id="set-theme" role="radiogroup" aria-label="Theme">${themes.map(([v, icon, label]) => `<button role="radio" data-theme="${v}" class="${s.theme === v ? 'on' : ''}" aria-checked="${s.theme === v}"><span>${icon}</span>${label}</button>`).join('')}</div>`, '', 'wide')}
2453
+ ${fld('Layout', `<select id="set-layout"><option value="below" ${s.layout === 'below' ? 'selected' : ''}>Response below the request</option><option value="beside" ${s.layout === 'beside' ? 'selected' : ''}>Response beside the request</option></select>`, 'Also switchable from the status bar.')}
2454
+ ${fld('Font', `<select id="set-uifont">${UI_FONTS.map(([v, label, stack]) => `<option value="${v}" style="font-family:${esc(stack)}" ${(s.ui_font || 'inter') === v ? 'selected' : ''}>${label}</option>`).join('')}</select>`, 'For menus, labels and the sidebar.')}
2455
+ ${fld('Code font', `<select id="set-monofont">${MONO_FONTS.map(([v, label, stack]) => `<option value="${v}" style="font-family:${esc(stack)}" ${(s.mono_font || 'default') === v ? 'selected' : ''}>${label}</option>`).join('')}</select>`, 'For URLs, bodies and responses. A font that is not installed falls back to the default.')}
2456
+ ${fld('Editor font size', `<span class="set-unit"><input type="number" id="set-font" min="10" max="24" value="${s.editor_font_size}"><span class="muted small">px</span></span>`, 'For bodies, scripts and the response viewer.')}
2457
+ </div>
2458
+ </div>
2459
+ <div class="set-section">
2460
+ <h4>Requests</h4>
2461
+ <div class="switch-list">
2462
+ ${sw('set-ssl', 'Verify SSL certificates', 'Applies to every Send and run. Turn off only for local or test servers with self-signed or invalid certificates.', s.verify_ssl)}
2463
+ <div id="set-ssl-warn" class="ssl-warn" ${s.verify_ssl ? 'hidden' : ''}>⚠ Certificates are not checked. Connections can be intercepted without warning.</div>
2464
+ ${sw('set-redirects', 'Follow redirects', 'Default for every request; a request can override it in its Settings tab.', s.follow_redirects)}
2465
+ ${sw('set-cookies', 'Send and store cookies', 'Keep a cookie jar like a browser. Each request can opt out in its Settings tab.', s.send_cookies)}
2466
+ ${sw('set-parallel', 'Send in parallel', 'Send ×N (several bodies) and runs send everything at once instead of one after another. Fastest, but no order and no {{prev.…}}. The run dialog can override this per run.', s.parallel)}
2467
+ </div>
2468
+ <div class="set-grid">
2469
+ ${fld('Max redirects', `<input type="number" id="set-maxredir" min="0" max="50" value="${s.max_redirects}">`, 'Stops a redirect loop.')}
2470
+ ${fld('Default timeout', `<span class="set-unit"><input type="number" id="set-timeout" min="1" max="3600" value="${s.default_timeout}"><span class="muted small">seconds</span></span>`, 'A request can set its own.')}
2471
+ ${fld('History size', `<input type="number" id="set-history" min="50" max="5000" value="${s.max_history}">`, 'Sends kept in Request history.')}
2472
+ ${fld('Parallel sends at once', `<input type="number" id="set-concurrency" min="1" max="64" value="${s.concurrency || 8}" ${s.parallel ? '' : 'disabled'}>`, '<span id="set-concurrency-hint"></span>')}
2473
+ ${fld('User-Agent', `<input id="set-ua" class="mono" value="${esc(s.user_agent || '')}" placeholder="pyhttp/…" spellcheck="false">`, '<span id="set-ua-hint">Sent with every request that has no User-Agent header of its own; leave it empty for the default.</span>', 'wide')}
2474
+ </div>
2475
+ </div>
2476
+ </div>
2477
+ <div class="tab-panel" data-panel="network" hidden>
2478
+ <div class="set-section">
2479
+ <h4>Proxy</h4>
2480
+ <div class="set-grid">
2481
+ ${fld('Proxy URL', `<input id="set-proxy" class="mono" value="${esc(s.proxy_url || '')}" placeholder="http://user:pass@proxy.local:3128" spellcheck="false">`, 'Used for every request whose host is not in the bypass list. Empty means no proxy.', 'wide')}
2482
+ ${fld('Bypass for', `<input id="set-proxy-bypass" class="mono" value="${esc(s.proxy_bypass || '')}" placeholder="localhost, 127.0.0.1, *.internal" spellcheck="false">`, 'Comma-separated hosts. They match exactly or as a suffix: <code>example.com</code> also matches <code>api.example.com</code>.', 'wide')}
2483
+ </div>
2484
+ </div>
2485
+ </div>
2486
+ <div class="tab-panel" data-panel="certs" hidden>
2487
+ <div class="set-section">
2488
+ <h4>Client certificates</h4>
2489
+ <p class="muted small set-intro">For servers that require mutual TLS. Give the host (or <code>*.example.com</code>), the PEM certificate file and, if the key is separate, the PEM key file.</p>
2490
+ <div class="cert-head muted small"><span>Host</span><span>Certificate file (PEM)</span><span>Key file (optional)</span><span></span></div>
2491
+ <div id="set-certs"></div>
2492
+ <button class="link" id="set-cert-add">+ Add certificate</button>
2493
+ </div>
2494
+ </div>
2495
+ <div class="tab-panel" data-panel="ai" hidden>
2496
+ <div class="set-section">
2497
+ <h4>MCP server</h4>
2498
+ <p class="small">pyhttp ships an <a href="https://modelcontextprotocol.io" target="_blank" rel="noopener">MCP</a> server, so AI tools
2499
+ (Claude Code, Claude Desktop, Cursor…) can manage this collection: list, create and edit requests, send them,
2500
+ run folders or tags like the collection runner, read history, switch environments and set variables.</p>
2501
+ <p class="small">It runs on this app's data folder — keep pyhttp open or closed, both work. The AI tool starts
2502
+ and stops the server by itself; there is nothing to keep running here.</p>
2503
+ </div>
2504
+ <div class="set-section">
2505
+ <h4>Claude Code</h4>
2506
+ <p class="muted small">Run this once in a terminal:</p>
2507
+ <div class="set-code"><pre id="mcp-cmd-code" class="mono">loading…</pre><button class="copy-btn" data-copy="mcp-cmd-code"><span>Copy</span></button></div>
2508
+ </div>
2509
+ <div class="set-section">
2510
+ <h4>Claude Desktop / Cursor / other MCP clients</h4>
2511
+ <p class="muted small">Add this to the client's MCP configuration (for Claude Desktop: <code>claude_desktop_config.json</code>):</p>
2512
+ <div class="set-code"><pre id="mcp-cmd-json" class="mono">loading…</pre><button class="copy-btn" data-copy="mcp-cmd-json"><span>Copy</span></button></div>
2513
+ </div>
2514
+ <div class="set-section">
2515
+ <h4>Try it</h4>
2516
+ <p class="muted small">Then ask the AI things like “list my pyhttp requests”, “create a POST request to … with this body and a 200 test”,
2517
+ “send Login and show the response”, or “run the Smoke folder and summarise the failures”.</p>
2518
+ </div>
2519
+ </div>
2520
+ <div class="tab-panel" data-panel="about" hidden>
2521
+ <div class="set-section">
2522
+ <h4>Updates</h4>
2523
+ <div class="switch-list">
2524
+ ${sw('set-update', 'Check for updates at start', 'Looks up the latest GitHub release once per hour and shows an Update button when a newer version exists.', s.update_check)}
2525
+ </div>
2526
+ <div class="set-grid">
2527
+ ${fld('GitHub repository', `<input id="set-repo" class="mono" value="${esc(s.update_repo || '')}" placeholder="owner/repo" spellcheck="false">`, 'Where releases are published. Packaged builds have this filled in.', 'wide')}
2528
+ </div>
2529
+ <div class="set-actions"><button id="set-update-now">Check now</button><span id="set-update-status" class="small"></span></div>
2530
+ <div class="muted small" id="set-version"></div>
2531
+ </div>
2532
+ <div class="set-section">
2533
+ <h4>Data</h4>
2534
+ <p class="muted small" id="set-datadir"></p>
2535
+ </div>
2536
+ <div class="set-section">
2537
+ <h4>License</h4>
2538
+ <p class="small"><strong>pyhttp Dev Team</strong> · <em>From the unknown to the unowned</em></p>
2539
+ <p class="small">Copyright © 2026 pyhttp Dev Team. Free for everyone under the pyhttp Free License 1.0 (LICENSE.md):
2540
+ use it at home or at work, at a company of any size, for any purpose, with no fees and no registration.
2541
+ Questions: <a href="mailto:dev@pyhttp.com">dev@pyhttp.com</a>.</p>
2542
+ <p class="muted small">Built with FastAPI, Starlette, Pydantic, Uvicorn, httpx, anyio, h11, idna, certifi, click and pywebview (MIT / BSD / PSF / MPL-2.0 licensed). See THIRD-PARTY-NOTICES.md.</p>
2543
+ </div>
2544
+ </div>
2545
+ </div>
2546
+ `, [{ label: 'Done', cls: 'primary', onClick: closeModal }]);
2547
+ bindTabs($('#set-tabs'), $('#set-panels'));
2548
+ $$('#set-theme button').forEach((b) => b.addEventListener('click', async () => {
2549
+ $$('#set-theme button').forEach((x) => { x.classList.toggle('on', x === b); x.setAttribute('aria-checked', x === b); });
2550
+ await saveSettings({ theme: b.dataset.theme });
2551
+ }));
2552
+ $('#set-ssl').addEventListener('change', async (e) => {
2553
+ $('#set-ssl-warn').hidden = e.target.checked;
2554
+ await saveSettings({ verify_ssl: e.target.checked });
2555
+ toast(e.target.checked ? 'SSL verification on' : 'SSL verification off', e.target.checked ? 'ok' : 'error');
2556
+ });
2557
+ $('#set-redirects').addEventListener('change', (e) => saveSettings({ follow_redirects: e.target.checked }));
2558
+ $('#set-cookies').addEventListener('change', (e) => saveSettings({ send_cookies: e.target.checked }));
2559
+ const concurrencyHint = () => {
2560
+ const on = $('#set-parallel').checked;
2561
+ $('#set-concurrency').disabled = !on;
2562
+ $('#set-concurrency-hint').textContent = on ? 'Upper limit for Send ×N and parallel runs.' : 'Turn on “Send in parallel” above to use this.';
2563
+ };
2564
+ $('#set-parallel').addEventListener('change', (e) => { saveSettings({ parallel: e.target.checked }); concurrencyHint(); if (typeof renderSendButton === 'function' && state.current) renderSendButton(); });
2565
+ concurrencyHint();
2566
+ $('#set-update').addEventListener('change', (e) => saveSettings({ update_check: e.target.checked }));
2567
+ $('#set-layout').addEventListener('change', (e) => saveSettings({ layout: e.target.value }));
2568
+ $('#set-uifont').addEventListener('change', (e) => saveSettings({ ui_font: e.target.value }));
2569
+ $('#set-monofont').addEventListener('change', (e) => saveSettings({ mono_font: e.target.value }));
2570
+ const num = (id, key, min, max) => $('#' + id).addEventListener('change', (e) => { const v = Math.min(max, Math.max(min, Number(e.target.value) || min)); e.target.value = v; saveSettings({ [key]: v }); });
2571
+ num('set-font', 'editor_font_size', 10, 24);
2572
+ num('set-maxredir', 'max_redirects', 0, 50);
2573
+ num('set-timeout', 'default_timeout', 1, 3600);
2574
+ num('set-history', 'max_history', 50, 5000);
2575
+ num('set-concurrency', 'concurrency', 1, 64);
2576
+ const txt = (id, key) => $('#' + id).addEventListener('change', (e) => saveSettings({ [key]: e.target.value.trim() }));
2577
+ txt('set-ua', 'user_agent'); txt('set-proxy', 'proxy_url'); txt('set-proxy-bypass', 'proxy_bypass'); txt('set-repo', 'update_repo');
2578
+ const certs = (s.certs || []).map((c) => ({ ...c }));
2579
+ const drawCerts = () => {
2580
+ const box = $('#set-certs');
2581
+ box.innerHTML = certs.length ? certs.map((c, i) => `<div class="cert-row"><input class="mono" data-i="${i}" data-k="host" value="${esc(c.host || '')}" placeholder="api.example.com"><input class="mono" data-i="${i}" data-k="cert" value="${esc(c.cert || '')}" placeholder="C:\\certs\\client.pem"><input class="mono" data-i="${i}" data-k="key" value="${esc(c.key || '')}" placeholder="key file (optional)"><button class="kv-del" data-del="${i}" title="Remove">✕</button></div>`).join('') : '<div class="muted small">No client certificates.</div>';
2582
+ $$('input', box).forEach((inp) => inp.addEventListener('change', () => { certs[inp.dataset.i][inp.dataset.k] = inp.value.trim(); saveSettings({ certs }); }));
2583
+ $$('[data-del]', box).forEach((b) => b.addEventListener('click', () => { certs.splice(Number(b.dataset.del), 1); saveSettings({ certs }); drawCerts(); }));
2584
+ };
2585
+ drawCerts();
2586
+ $('#set-cert-add').addEventListener('click', () => { certs.push({ host: '', cert: '', key: '' }); drawCerts(); $('#set-certs input').focus(); });
2587
+ $('#set-update-now').addEventListener('click', (e) => checkForUpdate(true, $('#set-update-status'), e.currentTarget));
2588
+ $$('#set-panels .set-code .copy-btn').forEach((b) => b.addEventListener('click', async () => {
2589
+ try {
2590
+ await navigator.clipboard.writeText($('#' + b.dataset.copy).textContent);
2591
+ b.classList.add('done');
2592
+ $('span', b).textContent = 'Copied';
2593
+ setTimeout(() => { b.classList.remove('done'); $('span', b).textContent = 'Copy'; }, 1600);
2594
+ } catch { toast('Could not copy; select the text instead', 'error'); }
2595
+ }));
2596
+ api('/meta').then((m) => {
2597
+ const kinds = { installer: 'Windows installer, updates install themselves', portable: 'portable .exe, updates install themselves', brew: 'installed with Homebrew, updates install themselves', app: 'macOS app, updates via Homebrew or the .dmg', source: 'running from source', deb: 'Ubuntu/Debian package, updates via the .deb', other: 'packaged build' };
2598
+ const how = m.install && kinds[m.install.kind] ? ' · ' + kinds[m.install.kind] : '';
2599
+ $('#set-version').textContent = `pyhttp ${m.version}${how}${m.github_repo ? ' · ' + m.github_repo : ' · no GitHub repository configured for update checks'}`;
2600
+ $('#set-datadir').textContent = `Database and run outputs: ${m.data_dir}`;
2601
+ // MCP setup commands, built from this very instance (same interpreter, same data folder)
2602
+ const q = (p) => (/[\s"']/.test(p) ? `"${p}"` : p);
2603
+ const argv = m.frozen ? [m.python, 'mcp', '--data-dir', m.data_dir]
2604
+ : [m.python, '-m', 'pyhttp', 'mcp', '--data-dir', m.data_dir];
2605
+ $('#mcp-cmd-code').textContent = 'claude mcp add pyhttp -- ' + argv.map(q).join(' ');
2606
+ $('#mcp-cmd-json').textContent = JSON.stringify(
2607
+ { mcpServers: { pyhttp: { command: argv[0], args: argv.slice(1) } } }, null, 2);
2608
+ if (m.user_agent) { $('#set-ua').placeholder = m.user_agent; $('#set-ua-hint').textContent = `Sent with every request that has no User-Agent header of its own. Empty means the default: ${m.user_agent}`; }
2609
+ }).catch(() => {});
2610
+ }
2611
+
2612
+ // --------------------------------------------------------------------- init
2613
+ function init() {
2614
+ bindTabs($('#editor-tabs'), $('#editor-panels'));
2615
+ bindTabs($('#resp-tabs'), $('#resp-panels'));
2616
+ bindTabs($('#runs-tabs'), $('#runs-panels'));
2617
+
2618
+ let searchTimer = null;
2619
+ $('#search').addEventListener('input', (e) => {
2620
+ clearTimeout(searchTimer);
2621
+ searchTimer = setTimeout(() => { state.q = e.target.value; loadRequests(); }, 150);
2622
+ });
2623
+ $('#btn-new').addEventListener('click', () => newRequest());
2624
+ $('#btn-new-folder').addEventListener('click', () => createFolder());
2625
+ bindDragAndDrop();
2626
+ $('#env-select').addEventListener('change', (e) => {
2627
+ const v = e.target.value;
2628
+ if (v === '__new') { renderEnvSelect(); openEnvManager(null, true); return; }
2629
+ if (v === '__manage') { renderEnvSelect(); openEnvManager(); return; }
2630
+ setActiveEnv(v);
2631
+ });
2632
+ $('#btn-envs').addEventListener('click', () => openEnvManager());
2633
+ $('#select-all').addEventListener('change', (e) => {
2634
+ if (e.target.checked) {
2635
+ state.treeOrder.forEach((r) => { if (!isSelected(r.id)) state.selected.push(selEntry(r)); });
2636
+ } else {
2637
+ const shown = new Set(state.requests.map((r) => r.id));
2638
+ state.selected = state.selected.filter((s) => !shown.has(s.id));
2639
+ }
2640
+ renderList();
2641
+ });
2642
+ $('#btn-clear-sel').addEventListener('click', () => { state.selected = []; renderList(); });
2643
+ $('#btn-run').addEventListener('click', () => openRunModal());
2644
+ $('#btn-run-now').addEventListener('click', runSelectedNow);
2645
+
2646
+ // autosave: any typing, pasting or toggling inside the editor
2647
+ const editorForm = $('#editor-form');
2648
+ const onEdit = (e) => { if (e.target.id !== 'body-search') scheduleSave(); };
2649
+ editorForm.addEventListener('input', onEdit);
2650
+ editorForm.addEventListener('change', onEdit);
2651
+ editorForm.addEventListener('click', (e) => { if (e.target.closest('.kv-del')) scheduleSave(); });
2652
+ window.addEventListener('beforeunload', saveOnExit);
2653
+ window.addEventListener('pagehide', saveOnExit);
2654
+ window.addEventListener('pagehide', commitPendingDeletes);
2655
+ $('#f-tags').addEventListener('input', renderMetaTags);
2656
+ $('#f-description').addEventListener('input', renderMetaTags);
2657
+ $('#btn-details').addEventListener('click', () => toggleDetails());
2658
+ try { if (localStorage.getItem('pyhttp.details') === '1') $('#details').hidden = false; } catch { /* ignore */ }
2659
+ $('#f-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); $('#f-url').focus(); } });
2660
+ $('#f-method').addEventListener('change', tintMethodSelect);
2661
+ $('#btn-send').addEventListener('click', sendOrCancel);
2662
+ $('#btn-send-mode').addEventListener('click', (e) => openSendModeMenu(e.currentTarget));
2663
+ $('#f-dep').addEventListener('change', refreshDependencyUi);
2664
+ $('#editor-tabs button[data-tab="settings"]').addEventListener('click', () => refreshDependencyUi());
2665
+ $('#f-dep-ttl').addEventListener('change', refreshDependencyUi);
2666
+ $('#f-dep-unit').addEventListener('change', refreshDependencyUi);
2667
+ $('#btn-format-json').addEventListener('click', beautifyBody);
2668
+ $('#f-body').addEventListener('input', () => {
2669
+ if ($('#f-body-type').value === 'none' && $('#f-body').value.trim()) $('#f-body-type').value = 'json';
2670
+ flushBody();
2671
+ renderBodies();
2672
+ });
2673
+ $('#body-name').addEventListener('input', () => { flushBody(); renderBodies(); });
2674
+ $('#f-body-type').addEventListener('change', () => {
2675
+ flushBody();
2676
+ const b = activeBody();
2677
+ if (b) showBodyEditor(b);
2678
+ updateBodyHints();
2679
+ updateTabCounts();
2680
+ });
2681
+ let bodySearchTimer = null;
2682
+ $('#body-search').addEventListener('input', (e) => {
2683
+ clearTimeout(bodySearchTimer);
2684
+ bodySearchTimer = setTimeout(() => { flushBody(); state.bodyQ = e.target.value.trim(); renderBodies(); }, 120);
2685
+ });
2686
+ $('#btn-body-add').addEventListener('click', () => addBody());
2687
+ $('#btn-body-dup').addEventListener('click', () => { flushBody(); if (activeBody()) addBody(activeBody()); });
2688
+ $('#btn-body-del').addEventListener('click', deleteBody);
2689
+ $('#btn-body-all').addEventListener('click', () => setAllBodies(true));
2690
+ $('#btn-body-none').addEventListener('click', () => setAllBodies(false));
2691
+
2692
+ $('#btn-variables').addEventListener('click', openVariables);
2693
+ $('#btn-export').addEventListener('click', (e) => exportMenu(e.currentTarget));
2694
+ $('#btn-import').addEventListener('click', importClicked);
2695
+ $('#import-file').addEventListener('change', (e) => {
2696
+ if (e.target.files[0]) importFile(e.target.files[0]);
2697
+ e.target.value = '';
2698
+ });
2699
+
2700
+ document.addEventListener('mousedown', (e) => { if (!e.target.closest('#ctx-menu, .more, #btn-export, #btn-send-mode')) closeMenu(); });
2701
+ $('#req-list').addEventListener('scroll', closeMenu);
2702
+ window.addEventListener('resize', closeMenu);
2703
+ $('#modal-close').addEventListener('click', closeModal);
2704
+ $('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); });
2705
+ document.addEventListener('keydown', (e) => {
2706
+ if (e.key === 'Escape' && $('#ctx-menu')) { closeMenu(); return; }
2707
+ if (e.key === 'Escape' && !$('#modal').hidden) closeModal();
2708
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && state.current) { e.preventDefault(); sendCurrent(); }
2709
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's' && state.current) { e.preventDefault(); autosave.dirty = true; saveNow().then(() => toast('Saved', 'ok')); }
2710
+ // list navigation, only while not typing and nothing modal is open
2711
+ const typing = e.target.closest && e.target.closest('input, textarea, select, button, [contenteditable], .cm-editor');
2712
+ if (typing || e.ctrlKey || e.metaKey || e.altKey || !$('#modal').hidden || $('.dialog-backdrop')) return;
2713
+ if (e.key === 'ArrowDown') { e.preventDefault(); kbMove(1); return; }
2714
+ if (e.key === 'ArrowUp') { e.preventDefault(); kbMove(-1); return; }
2715
+ if (!state.kbId) return;
2716
+ const r = state.requests.find((x) => x.id === state.kbId);
2717
+ if (e.key === 'Enter') { e.preventDefault(); openRequest(state.kbId); }
2718
+ else if (e.key === ' ' && r) { e.preventDefault(); toggleSelect(r); }
2719
+ else if (e.key === 'Delete' && r) { e.preventDefault(); deleteRequestById(r.id, r.name); }
2720
+ else if (e.key === 'Escape') { state.kbId = null; renderList(); }
2721
+ });
2722
+
2723
+ api('/meta').then((m) => { const el = $('#meta-info'); el.textContent = `v${m.version}`; el.title = 'Run outputs are saved in ' + m.output_dir; }).catch(() => {});
2724
+ bindConsole();
2725
+ loadSettings();
2726
+ $('#r-copy').addEventListener('click', copyResponse);
2727
+ $$('#resp-tabs button').forEach((b) => b.addEventListener('click', updateCopyButton));
2728
+ $('#btn-settings').addEventListener('click', openSettings);
2729
+ $('#sb-ssl').addEventListener('click', openSettings);
2730
+ $('#link-settings').addEventListener('click', openSettings);
2731
+ loadExpanded();
2732
+ loadRequests();
2733
+ loadEnvironments();
2734
+ loadHistory();
2735
+ }
2736
+ document.addEventListener('DOMContentLoaded', init);
2737
+ function markNative() { if (window.pywebview) document.body.classList.add('native'); }
2738
+ window.addEventListener('pywebviewready', markNative);
2739
+ document.addEventListener('DOMContentLoaded', markNative);