rails-http-lab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +15 -0
  3. data/README.md +60 -0
  4. data/app/assets/javascripts/rails_http_lab/application.js +1318 -0
  5. data/app/assets/stylesheets/rails_http_lab/application.css +336 -0
  6. data/app/controllers/rails_http_lab/application_controller.rb +47 -0
  7. data/app/controllers/rails_http_lab/collections_controller.rb +20 -0
  8. data/app/controllers/rails_http_lab/environments_controller.rb +33 -0
  9. data/app/controllers/rails_http_lab/folders_controller.rb +47 -0
  10. data/app/controllers/rails_http_lab/requests_controller.rb +99 -0
  11. data/app/controllers/rails_http_lab/runs_controller.rb +34 -0
  12. data/app/controllers/rails_http_lab/ui_controller.rb +7 -0
  13. data/app/views/layouts/rails_http_lab.html.erb +14 -0
  14. data/app/views/rails_http_lab/ui/index.html.erb +103 -0
  15. data/config/routes.rb +24 -0
  16. data/lib/generators/rails_http_lab/install/install_generator.rb +41 -0
  17. data/lib/generators/rails_http_lab/install/templates/initializer.rb.tt +20 -0
  18. data/lib/rails-http-lab.rb +1 -0
  19. data/lib/rails_http_lab/bruno/block.rb +44 -0
  20. data/lib/rails_http_lab/bruno/document.rb +36 -0
  21. data/lib/rails_http_lab/bruno/parser.rb +207 -0
  22. data/lib/rails_http_lab/bruno/serializer.rb +68 -0
  23. data/lib/rails_http_lab/bruno.rb +12 -0
  24. data/lib/rails_http_lab/configuration.rb +51 -0
  25. data/lib/rails_http_lab/engine.rb +25 -0
  26. data/lib/rails_http_lab/execution/response.rb +20 -0
  27. data/lib/rails_http_lab/execution/runner.rb +187 -0
  28. data/lib/rails_http_lab/execution/variable_resolver.rb +30 -0
  29. data/lib/rails_http_lab/execution.rb +3 -0
  30. data/lib/rails_http_lab/storage/filesystem.rb +123 -0
  31. data/lib/rails_http_lab/storage/tree.rb +120 -0
  32. data/lib/rails_http_lab/storage.rb +2 -0
  33. data/lib/rails_http_lab/version.rb +3 -0
  34. data/lib/rails_http_lab.rb +14 -0
  35. metadata +92 -0
@@ -0,0 +1,1318 @@
1
+ (function () {
2
+ "use strict";
3
+
4
+ const BASE = (document.querySelector('meta[name="rhl-base"]') || {}).content || "";
5
+ const CSRF = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
6
+ const API = (p) => `${BASE}/api${p}`;
7
+ const VERBS = ["get","post","put","patch","delete","head","options"];
8
+
9
+ // Body type → block name suffix (Bruno format)
10
+ const BODY_BLOCK = {
11
+ json: "body:json",
12
+ text: "body:text",
13
+ xml: "body:xml",
14
+ graphql: "body:graphql",
15
+ sparql: "body:sparql",
16
+ formUrlEncoded: "body:form-urlencoded",
17
+ multipartForm: "body:multipart-form",
18
+ file: "body:file"
19
+ };
20
+ const BODY_KINDS_RAW = ["json", "text", "xml", "graphql", "sparql"];
21
+ const BODY_KINDS_KV = ["formUrlEncoded", "multipartForm"];
22
+ const BODY_KINDS_ALL = ["none", ...BODY_KINDS_RAW, ...BODY_KINDS_KV];
23
+
24
+ // HTTP reason phrases (RFC 9110 + common extensions).
25
+ const STATUS_TEXT = {
26
+ 100: "Continue", 101: "Switching Protocols", 102: "Processing", 103: "Early Hints",
27
+ 200: "OK", 201: "Created", 202: "Accepted", 203: "Non-Authoritative Information",
28
+ 204: "No Content", 205: "Reset Content", 206: "Partial Content", 207: "Multi-Status",
29
+ 208: "Already Reported", 226: "IM Used",
30
+ 300: "Multiple Choices", 301: "Moved Permanently", 302: "Found", 303: "See Other",
31
+ 304: "Not Modified", 307: "Temporary Redirect", 308: "Permanent Redirect",
32
+ 400: "Bad Request", 401: "Unauthorized", 402: "Payment Required", 403: "Forbidden",
33
+ 404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable",
34
+ 407: "Proxy Authentication Required", 408: "Request Timeout", 409: "Conflict",
35
+ 410: "Gone", 411: "Length Required", 412: "Precondition Failed",
36
+ 413: "Payload Too Large", 414: "URI Too Long", 415: "Unsupported Media Type",
37
+ 416: "Range Not Satisfiable", 417: "Expectation Failed", 418: "I'm a teapot",
38
+ 421: "Misdirected Request", 422: "Unprocessable Entity", 423: "Locked",
39
+ 424: "Failed Dependency", 425: "Too Early", 426: "Upgrade Required",
40
+ 428: "Precondition Required", 429: "Too Many Requests",
41
+ 431: "Request Header Fields Too Large", 451: "Unavailable For Legal Reasons",
42
+ 500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway",
43
+ 503: "Service Unavailable", 504: "Gateway Timeout", 505: "HTTP Version Not Supported",
44
+ 506: "Variant Also Negotiates", 507: "Insufficient Storage", 508: "Loop Detected",
45
+ 510: "Not Extended", 511: "Network Authentication Required"
46
+ };
47
+
48
+ // ---------- State ----------
49
+ // One *session* per request path holds the working doc (with unsaved edits),
50
+ // the last /run response, and which tabs were open — so navigating between
51
+ // requests never loses data. Sessions are mirrored to localStorage so they
52
+ // also survive a page reload (see persistSessions/hydrateSessions).
53
+ const state = {
54
+ tree: null,
55
+ currentKey: null, // active request path == session map key
56
+ envName: "",
57
+ expandedFolders: new Set(),
58
+ sessions: new Map(), // path -> session
59
+ };
60
+
61
+ // localStorage config
62
+ const LS_KEY = `rhl:${BASE || "/"}:sessions`;
63
+ const LS_MAX_SESSIONS = 30; // LRU cap
64
+ const LS_MAX_BODY = 200 * 1024; // per-response body cap for persistence
65
+ let hydratedKey = null; // currentKey restored from storage
66
+
67
+ function makeSession(path, doc) {
68
+ return {
69
+ path,
70
+ doc,
71
+ savedDoc: cloneDoc(doc), // last on-disk version, for dirty detection
72
+ activeTab: "params",
73
+ responseTab: "body",
74
+ response: null, // last /run result, or { error }
75
+ touched: Date.now(),
76
+ _wasDirty: false,
77
+ };
78
+ }
79
+ function activeSession() { return state.currentKey ? (state.sessions.get(state.currentKey) || null) : null; }
80
+ function currentDoc() { return activeSession()?.doc || null; }
81
+ function cloneDoc(doc) { return doc ? JSON.parse(JSON.stringify(doc)) : doc; }
82
+
83
+ // Dirty = working doc differs from the saved version, comparing *normalized*
84
+ // sources so that merely viewing a tab (which lazily injects an empty block)
85
+ // or leaving a blank "+ Add row" doesn't count as a change.
86
+ function normalizedSource(doc) {
87
+ if (!doc) return "";
88
+ const blocks = (doc.blocks || []).filter((b) => {
89
+ if (b.mode === "raw") return (b.raw || "").trim() !== "";
90
+ const pairs = (b.pairs || []).filter(([k, v]) =>
91
+ (k || "").trim() !== "" || (v || "").trim() !== "");
92
+ return pairs.length > 0;
93
+ });
94
+ return serializeDoc({ blocks });
95
+ }
96
+ function isDirty(session) {
97
+ if (!session) return false;
98
+ return normalizedSource(session.doc) !== normalizedSource(session.savedDoc);
99
+ }
100
+
101
+ function ensureExpanded(path) {
102
+ if (!path) return;
103
+ const parts = path.split("/");
104
+ let cur = "";
105
+ for (const part of parts) {
106
+ cur = cur ? `${cur}/${part}` : part;
107
+ state.expandedFolders.add(cur);
108
+ }
109
+ }
110
+ function parentDir(path) {
111
+ if (!path) return "";
112
+ const i = path.lastIndexOf("/");
113
+ return i < 0 ? "" : path.slice(0, i);
114
+ }
115
+
116
+ // ---------- Helpers ----------
117
+ const $ = (sel) => document.querySelector(sel);
118
+ const ce = (tag, attrs = {}, ...children) => {
119
+ const el = document.createElement(tag);
120
+ for (const [k, v] of Object.entries(attrs || {})) {
121
+ if (k === "class") el.className = v;
122
+ else if (k === "dataset") Object.assign(el.dataset, v);
123
+ else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
124
+ else if (v === true) el.setAttribute(k, "");
125
+ else if (v !== false && v != null) el.setAttribute(k, v);
126
+ }
127
+ for (const c of children) {
128
+ if (c == null) continue;
129
+ el.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
130
+ }
131
+ return el;
132
+ };
133
+
134
+ async function api(method, path, body) {
135
+ const opts = { method, headers: { "Accept": "application/json" } };
136
+ if (body !== undefined) {
137
+ opts.headers["Content-Type"] = "application/json";
138
+ opts.headers["X-CSRF-Token"] = CSRF;
139
+ opts.body = JSON.stringify(body);
140
+ }
141
+ const res = await fetch(API(path), opts);
142
+ if (!res.ok) {
143
+ let detail = "";
144
+ try { detail = (await res.json()).error || ""; } catch (_) {}
145
+ throw new Error(`${res.status} ${res.statusText}${detail ? " — " + detail : ""}`);
146
+ }
147
+ if (res.status === 204) return null;
148
+ return res.json();
149
+ }
150
+
151
+ // ---------- Doc utilities ----------
152
+ function findVerbBlock(doc) { return (doc?.blocks || []).find((b) => VERBS.includes(b.name)); }
153
+ function findBlock(doc, name) { return (doc?.blocks || []).find((b) => b.name === name); }
154
+ function docMethod(doc) { return (findVerbBlock(doc)?.name || "get").toUpperCase(); }
155
+ function docUrl(doc) {
156
+ const v = findVerbBlock(doc);
157
+ return (v && (v.pairs.find(([k]) => k === "url") || [])[1]) || "";
158
+ }
159
+
160
+ function ensureKvBlock(name) {
161
+ const doc = currentDoc();
162
+ let b = findBlock(doc, name);
163
+ if (!b) { b = { name, mode: "kv", pairs: [] }; doc.blocks.push(b); }
164
+ if (!Array.isArray(b.pairs)) b.pairs = [];
165
+ return b;
166
+ }
167
+ function ensureRawBlock(name) {
168
+ const doc = currentDoc();
169
+ let b = findBlock(doc, name);
170
+ if (!b) { b = { name, mode: "raw", raw: "" }; doc.blocks.push(b); }
171
+ if (typeof b.raw !== "string") b.raw = "";
172
+ return b;
173
+ }
174
+ function getVerbPair(key) {
175
+ const v = findVerbBlock(currentDoc());
176
+ if (!v) return null;
177
+ return v.pairs.find(([k]) => k === key);
178
+ }
179
+ function setVerbPair(key, value) {
180
+ const doc = currentDoc();
181
+ if (!doc) return;
182
+ let v = findVerbBlock(doc);
183
+ if (!v) {
184
+ v = { name: "get", mode: "kv", pairs: [["url", ""], ["body", "none"], ["auth", "none"]] };
185
+ doc.blocks.push(v);
186
+ }
187
+ const pair = v.pairs.find(([k]) => k === key);
188
+ if (pair) pair[1] = value; else v.pairs.push([key, value]);
189
+ }
190
+ function setVerbName(name) {
191
+ const doc = currentDoc();
192
+ if (!doc) return;
193
+ let v = findVerbBlock(doc);
194
+ if (!v) {
195
+ doc.blocks.push({ name, mode: "kv", pairs: [["url",""],["body","none"],["auth","none"]] });
196
+ } else {
197
+ v.name = name;
198
+ }
199
+ }
200
+
201
+ // ---------- .bru source serializer (client-side) ----------
202
+ function serializeDoc(doc) {
203
+ return (doc.blocks || []).map((b) => {
204
+ if (b.mode === "raw") {
205
+ const body = b.raw || "";
206
+ const tail = body.endsWith("\n") || body === "" ? "" : "\n";
207
+ return `${b.name} {\n${body}${tail}}\n`;
208
+ }
209
+ const lines = (b.pairs || []).map(([k, v]) => ` ${k}: ${v}`).join("\n");
210
+ return `${b.name} {\n${lines}${lines ? "\n" : ""}}\n`;
211
+ }).join("\n");
212
+ }
213
+
214
+ // ---------- Session persistence (localStorage) ----------
215
+ let persistTimer = null;
216
+ function schedulePersist() {
217
+ if (persistTimer) return;
218
+ persistTimer = setTimeout(() => { persistTimer = null; persistSessions(); }, 400);
219
+ }
220
+
221
+ function trimResponseForStorage(resp) {
222
+ if (!resp) return null;
223
+ const body = resp.body != null ? String(resp.body) : resp.body;
224
+ if (body && body.length > LS_MAX_BODY) {
225
+ const copy = { ...resp };
226
+ copy.body = null;
227
+ copy.bodyTruncated = true;
228
+ return copy;
229
+ }
230
+ return resp;
231
+ }
232
+
233
+ function persistSessions() {
234
+ try {
235
+ const entries = [...state.sessions.entries()]
236
+ .sort((a, b) => (b[1].touched || 0) - (a[1].touched || 0))
237
+ .slice(0, LS_MAX_SESSIONS);
238
+ const payload = { v: 1, currentKey: state.currentKey, envName: state.envName, sessions: {} };
239
+ for (const [key, s] of entries) {
240
+ payload.sessions[key] = {
241
+ path: s.path,
242
+ doc: s.doc,
243
+ savedDoc: s.savedDoc,
244
+ activeTab: s.activeTab,
245
+ responseTab: s.responseTab,
246
+ touched: s.touched,
247
+ response: trimResponseForStorage(s.response),
248
+ };
249
+ }
250
+ localStorage.setItem(LS_KEY, JSON.stringify(payload));
251
+ } catch (_) {
252
+ // QuotaExceededError or storage disabled — degrade silently.
253
+ }
254
+ }
255
+
256
+ function hydrateSessions() {
257
+ try {
258
+ const raw = localStorage.getItem(LS_KEY);
259
+ if (!raw) return;
260
+ const payload = JSON.parse(raw);
261
+ if (!payload || !payload.sessions) return;
262
+ if (payload.envName) state.envName = payload.envName;
263
+ hydratedKey = payload.currentKey || null;
264
+ for (const [key, s] of Object.entries(payload.sessions)) {
265
+ if (!s || !s.doc) continue;
266
+ state.sessions.set(key, {
267
+ path: s.path || key,
268
+ doc: s.doc,
269
+ savedDoc: s.savedDoc || cloneDoc(s.doc),
270
+ activeTab: s.activeTab || "params",
271
+ responseTab: s.responseTab || "body",
272
+ response: s.response || null,
273
+ touched: s.touched || Date.now(),
274
+ _wasDirty: false,
275
+ });
276
+ }
277
+ } catch (_) {}
278
+ }
279
+
280
+ function collectRequestPaths(node, acc) {
281
+ for (const c of (node.children || [])) {
282
+ if (c.type === "request") acc.add(c.path);
283
+ else if (c.type === "folder") collectRequestPaths(c, acc);
284
+ }
285
+ return acc;
286
+ }
287
+ // Drop cached sessions whose request no longer exists on disk (deleted
288
+ // externally / in another tab), so stale drafts don't linger.
289
+ function pruneSessions() {
290
+ if (!state.tree) return;
291
+ const valid = collectRequestPaths(state.tree, new Set());
292
+ for (const key of [...state.sessions.keys()]) {
293
+ if (!valid.has(key)) state.sessions.delete(key);
294
+ }
295
+ if (state.currentKey && !state.sessions.has(state.currentKey)) state.currentKey = null;
296
+ }
297
+
298
+ function restoreLastSession() {
299
+ if (!hydratedKey) return;
300
+ const s = state.sessions.get(hydratedKey);
301
+ if (!s) return;
302
+ state.currentKey = hydratedKey;
303
+ showEditorForSession(s);
304
+ }
305
+
306
+ function clearCache() {
307
+ if (!window.confirm("Clear all locally cached drafts and responses?\nSaved .bru files on disk are NOT affected.")) return;
308
+ state.sessions.clear();
309
+ state.currentKey = null;
310
+ try { localStorage.removeItem(LS_KEY); } catch (_) {}
311
+ $("#rhl-empty").hidden = false;
312
+ $("#rhl-editor").hidden = true;
313
+ resetResponse();
314
+ updateSaveDirty();
315
+ if (state.tree) renderTree();
316
+ }
317
+
318
+ // ---------- Tree (sidebar) ----------
319
+ async function loadTree() {
320
+ state.tree = await api("GET", "/tree");
321
+ renderTree();
322
+ renderEnvSelect();
323
+ }
324
+
325
+ function renderTree() {
326
+ const root = $("#rhl-tree");
327
+ const prevScroll = root.scrollTop;
328
+ root.innerHTML = "";
329
+ if (!state.tree || !state.tree.children.length) {
330
+ root.appendChild(ce("div", { class: "rhl-tree-empty" }, "No collections yet. Use + to create one."));
331
+ return;
332
+ }
333
+ for (const child of state.tree.children) root.appendChild(renderNode(child, 0));
334
+ root.scrollTop = prevScroll;
335
+ }
336
+
337
+ function renderNode(node, depth) {
338
+ const indent = { style: `padding-left: ${14 + depth * 12}px` };
339
+ if (node.type === "folder") {
340
+ const isOpen = state.expandedFolders.has(node.path);
341
+ const caret = ce("span", { class: "rhl-tree-caret" }, isOpen ? "▾" : "▸");
342
+ const label = ce("span", { class: "rhl-tree-label" }, node.name);
343
+ const actions = ce("button", {
344
+ class: "rhl-tree-action", title: "Folder actions",
345
+ onclick: (e) => { e.stopPropagation(); openFolderMenu(e.currentTarget, node.path); }
346
+ }, "⋯");
347
+ const header = ce("div", { class: "rhl-tree-node is-folder", ...indent }, caret, label, actions);
348
+ const childWrap = ce("div", { class: "rhl-tree-children" });
349
+ childWrap.hidden = !isOpen;
350
+ header.addEventListener("click", () => {
351
+ const nowOpen = !state.expandedFolders.has(node.path);
352
+ if (nowOpen) state.expandedFolders.add(node.path);
353
+ else state.expandedFolders.delete(node.path);
354
+ caret.textContent = nowOpen ? "▾" : "▸";
355
+ childWrap.hidden = !nowOpen;
356
+ });
357
+ for (const c of node.children || []) childWrap.appendChild(renderNode(c, depth + 1));
358
+ return ce("div", {}, header, childWrap);
359
+ }
360
+ if (node.type === "request") {
361
+ const verb = (node.method || "GET").toUpperCase();
362
+ const badge = ce("span", { class: `rhl-verb-badge ${verb}` }, verb);
363
+ const label = ce("span", { class: "rhl-tree-label" }, node.name);
364
+ const session = state.sessions.get(node.path);
365
+ if (session && isDirty(session)) {
366
+ label.appendChild(ce("span", { class: "rhl-dirty-dot", title: "Unsaved changes" }, "●"));
367
+ }
368
+ const isActive = state.currentKey === node.path;
369
+ const actions = ce("button", {
370
+ class: "rhl-tree-action", title: "Request actions",
371
+ onclick: (e) => { e.stopPropagation(); openRequestMenu(e.currentTarget, node.path); }
372
+ }, "⋯");
373
+ const row = ce("div", {
374
+ class: `rhl-tree-node is-request${isActive ? " is-active" : ""}`,
375
+ ...indent
376
+ }, badge, label, actions);
377
+ row.addEventListener("click", () => openRequest(node.path).catch((e) => showError(e.message)));
378
+ return row;
379
+ }
380
+ return ce("div");
381
+ }
382
+
383
+ // ---------- Tree actions (collection / folder / request creation) ----------
384
+ let openMenuEl = null;
385
+ function closeTreeMenu() {
386
+ if (openMenuEl) { openMenuEl.remove(); openMenuEl = null; }
387
+ }
388
+
389
+ function openFolderMenu(anchor, folderPath) {
390
+ openMenu(anchor, [
391
+ ["New request", () => promptNewRequest(folderPath)],
392
+ ["New folder", () => promptNewFolder(folderPath)],
393
+ ["Rename folder", () => promptRenameFolder(folderPath)],
394
+ ["Delete folder", () => confirmDeleteFolder(folderPath), "rhl-menu__item--danger"],
395
+ ]);
396
+ }
397
+
398
+ function openRequestMenu(anchor, requestPath) {
399
+ openMenu(anchor, [
400
+ ["Rename", () => promptRenameRequest(requestPath)],
401
+ ["Delete", () => confirmDeleteRequest(requestPath), "rhl-menu__item--danger"],
402
+ ]);
403
+ }
404
+
405
+ function openMenu(anchor, items) {
406
+ closeTreeMenu();
407
+ const rect = anchor.getBoundingClientRect();
408
+ const menu = ce("div", { class: "rhl-menu" });
409
+ menu.style.left = `${Math.round(rect.right + 4)}px`;
410
+ menu.style.top = `${Math.round(rect.top)}px`;
411
+ for (const [label, onclick, extraClass] of items) {
412
+ menu.appendChild(menuItem(label, () => { closeTreeMenu(); onclick(); }, extraClass));
413
+ }
414
+ document.body.appendChild(menu);
415
+ openMenuEl = menu;
416
+ setTimeout(() => {
417
+ const onDocClick = (e) => {
418
+ if (!menu.contains(e.target)) {
419
+ document.removeEventListener("click", onDocClick, true);
420
+ closeTreeMenu();
421
+ }
422
+ };
423
+ document.addEventListener("click", onDocClick, true);
424
+ }, 0);
425
+ }
426
+
427
+ function menuItem(label, onclick, extraClass) {
428
+ const cls = `rhl-menu__item${extraClass ? " " + extraClass : ""}`;
429
+ return ce("button", { class: cls, onclick }, label);
430
+ }
431
+
432
+ function sanitizeSegment(name) {
433
+ return name.trim().replace(/[/\\]/g, "_");
434
+ }
435
+
436
+ async function promptNewCollection() {
437
+ const name = window.prompt("New collection name");
438
+ if (!name || !name.trim()) return;
439
+ const safe = sanitizeSegment(name);
440
+ try {
441
+ await api("POST", "/folders", { path: safe, name: safe });
442
+ await loadTree();
443
+ } catch (e) { showError(e.message); }
444
+ }
445
+
446
+ async function promptNewFolder(parentPath) {
447
+ const label = parentPath ? `New folder inside "${parentPath}"` : "New folder name";
448
+ const name = window.prompt(label);
449
+ if (!name || !name.trim()) return;
450
+ const safe = sanitizeSegment(name);
451
+ const rel = parentPath ? `${parentPath}/${safe}` : safe;
452
+ try {
453
+ await api("POST", "/folders", { path: rel, name: safe });
454
+ ensureExpanded(parentPath);
455
+ await loadTree();
456
+ } catch (e) { showError(e.message); }
457
+ }
458
+
459
+ async function promptNewRequest(folderPath) {
460
+ const label = folderPath ? `New request inside "${folderPath}" (without .bru)` : "New request name (without .bru)";
461
+ const name = window.prompt(label);
462
+ if (!name || !name.trim()) return;
463
+ const safe = sanitizeSegment(name).replace(/\.bru$/i, "");
464
+ const rel = (folderPath ? `${folderPath}/` : "") + `${safe}.bru`;
465
+ const doc = { blocks: [
466
+ { name: "meta", mode: "kv", pairs: [["name", safe], ["type", "http"], ["seq", "1"]] },
467
+ { name: "get", mode: "kv", pairs: [["url", "https://example.com"], ["body", "none"], ["auth", "none"]] }
468
+ ]};
469
+ try {
470
+ await api("POST", "/requests", { path: rel, source: serializeDoc(doc) });
471
+ ensureExpanded(folderPath);
472
+ await loadTree();
473
+ await openRequest(rel);
474
+ } catch (e) { showError(e.message); }
475
+ }
476
+
477
+ async function promptRenameFolder(folderPath) {
478
+ const currentName = folderPath.split("/").pop();
479
+ const next = window.prompt(`Rename folder "${currentName}" to:`, currentName);
480
+ if (next == null) return;
481
+ const trimmed = next.trim();
482
+ if (!trimmed || trimmed === currentName) return;
483
+ const safe = sanitizeSegment(trimmed);
484
+ try {
485
+ const resp = await api("POST", "/folders/rename", { path: folderPath, name: safe });
486
+ const newPath = resp.path || joinPath(parentDir(folderPath), safe);
487
+ migrateExpandedPath(folderPath, newPath);
488
+ migrateSessionSubtree(folderPath, newPath);
489
+ await loadTree();
490
+ schedulePersist();
491
+ } catch (e) { showError(e.message); }
492
+ }
493
+
494
+ async function confirmDeleteFolder(folderPath) {
495
+ if (!window.confirm(`Delete folder "${folderPath}" and all its contents? This cannot be undone.`)) return;
496
+ try {
497
+ await api("DELETE", `/folders/${encodePath(folderPath)}`);
498
+ forgetExpandedSubtree(folderPath);
499
+ forgetSessionSubtree(folderPath);
500
+ if (state.currentKey && pathInsideOrEqual(state.currentKey, folderPath)) {
501
+ closeCurrentRequest();
502
+ }
503
+ await loadTree();
504
+ schedulePersist();
505
+ } catch (e) { showError(e.message); }
506
+ }
507
+
508
+ async function promptRenameRequest(requestPath) {
509
+ const currentName = requestPath.split("/").pop().replace(/\.bru$/i, "");
510
+ const next = window.prompt(`Rename request "${currentName}" to:`, currentName);
511
+ if (next == null) return;
512
+ const trimmed = next.trim();
513
+ if (!trimmed || trimmed === currentName) return;
514
+ const safe = sanitizeSegment(trimmed).replace(/\.bru$/i, "");
515
+ try {
516
+ const resp = await api("POST", "/requests/rename", { path: requestPath, name: safe });
517
+ const newPath = resp.path || joinPath(parentDir(requestPath), `${safe}.bru`);
518
+ migrateSessionKey(requestPath, newPath);
519
+ if (state.currentKey === requestPath) state.currentKey = newPath;
520
+ await loadTree();
521
+ schedulePersist();
522
+ } catch (e) { showError(e.message); }
523
+ }
524
+
525
+ async function confirmDeleteRequest(requestPath) {
526
+ if (!window.confirm(`Delete request "${requestPath}"? This cannot be undone.`)) return;
527
+ try {
528
+ await api("DELETE", `/requests/${encodePath(requestPath)}`);
529
+ state.sessions.delete(requestPath);
530
+ if (state.currentKey === requestPath) closeCurrentRequest();
531
+ await loadTree();
532
+ schedulePersist();
533
+ } catch (e) { showError(e.message); }
534
+ }
535
+
536
+ function joinPath(parent, child) {
537
+ return parent ? `${parent}/${child}` : child;
538
+ }
539
+ function pathInsideOrEqual(p, prefix) {
540
+ return p === prefix || p.startsWith(prefix + "/");
541
+ }
542
+ function migrateExpandedPath(oldPath, newPath) {
543
+ const updated = new Set();
544
+ for (const p of state.expandedFolders) {
545
+ if (p === oldPath) updated.add(newPath);
546
+ else if (p.startsWith(oldPath + "/")) updated.add(newPath + p.slice(oldPath.length));
547
+ else updated.add(p);
548
+ }
549
+ state.expandedFolders = updated;
550
+ }
551
+ function forgetExpandedSubtree(folderPath) {
552
+ for (const p of [...state.expandedFolders]) {
553
+ if (pathInsideOrEqual(p, folderPath)) state.expandedFolders.delete(p);
554
+ }
555
+ }
556
+ function migrateSessionKey(oldPath, newPath) {
557
+ const s = state.sessions.get(oldPath);
558
+ if (!s) return;
559
+ state.sessions.delete(oldPath);
560
+ s.path = newPath;
561
+ state.sessions.set(newPath, s);
562
+ }
563
+ function migrateSessionSubtree(oldPrefix, newPrefix) {
564
+ for (const key of [...state.sessions.keys()]) {
565
+ if (key === oldPrefix || key.startsWith(oldPrefix + "/")) {
566
+ const s = state.sessions.get(key);
567
+ state.sessions.delete(key);
568
+ const nk = newPrefix + key.slice(oldPrefix.length);
569
+ s.path = nk;
570
+ state.sessions.set(nk, s);
571
+ if (state.currentKey === key) state.currentKey = nk;
572
+ }
573
+ }
574
+ }
575
+ function forgetSessionSubtree(folderPath) {
576
+ for (const key of [...state.sessions.keys()]) {
577
+ if (pathInsideOrEqual(key, folderPath)) state.sessions.delete(key);
578
+ }
579
+ }
580
+ function closeCurrentRequest() {
581
+ state.currentKey = null;
582
+ $("#rhl-empty").hidden = false;
583
+ $("#rhl-editor").hidden = true;
584
+ resetResponse();
585
+ updateSaveDirty();
586
+ schedulePersist();
587
+ }
588
+
589
+ function renderEnvSelect() {
590
+ const sel = $("#rhl-env-select");
591
+ const previousValue = sel.value;
592
+ sel.innerHTML = "";
593
+ sel.appendChild(ce("option", { value: "" }, "(none)"));
594
+ for (const env of (state.tree?.environments || [])) {
595
+ sel.appendChild(ce("option", { value: env.name }, env.name));
596
+ }
597
+ // Restore in priority: explicit state.envName, then whatever the select
598
+ // was showing before (handles browser form-state restoration on reload).
599
+ const candidate = state.envName || previousValue || "";
600
+ const valid = Array.from(sel.options).some((o) => o.value === candidate);
601
+ sel.value = valid ? candidate : "";
602
+ state.envName = sel.value;
603
+ }
604
+
605
+ // ---------- Open / render request ----------
606
+ async function openRequest(path) {
607
+ // Persist any in-flight edits of the request we're leaving.
608
+ syncVerbAndUrlIntoDoc();
609
+
610
+ let session = state.sessions.get(path);
611
+ if (!session) {
612
+ const doc = await api("GET", `/requests/${encodePath(path)}`);
613
+ session = makeSession(path, doc);
614
+ state.sessions.set(path, session);
615
+ }
616
+ session.touched = Date.now();
617
+ state.currentKey = path;
618
+ showEditorForSession(session);
619
+ schedulePersist();
620
+ }
621
+
622
+ // Renders the editor + response area from a session (no network).
623
+ function showEditorForSession(session) {
624
+ $("#rhl-empty").hidden = true;
625
+ $("#rhl-editor").hidden = false;
626
+ $("#rhl-verb").value = docMethod(session.doc);
627
+ $("#rhl-url").value = docUrl(session.doc);
628
+ setActiveTab(session.activeTab || "params");
629
+ renderResponseFromSession(session);
630
+ updateSaveDirty();
631
+ if (state.tree) renderTree();
632
+ }
633
+
634
+ function renderResponseFromSession(session) {
635
+ if (session.response) {
636
+ showResponse(session.response);
637
+ } else {
638
+ resetResponse();
639
+ }
640
+ setResponseTab(session.responseTab || "body");
641
+ }
642
+
643
+ function encodePath(path) {
644
+ return path.split("/").map(encodeURIComponent).join("/");
645
+ }
646
+
647
+ // ---------- Tab panel ----------
648
+ function setActiveTab(tab) {
649
+ const s = activeSession();
650
+ if (s) s.activeTab = tab;
651
+ document.querySelectorAll(".rhl-tab").forEach((el) => {
652
+ el.classList.toggle("is-active", el.dataset.tab === tab);
653
+ });
654
+ renderPanel();
655
+ }
656
+
657
+ function renderPanel() {
658
+ const panel = $("#rhl-panel");
659
+ panel.innerHTML = "";
660
+ const tab = activeSession()?.activeTab || "params";
661
+ try {
662
+ switch (tab) {
663
+ case "params": return renderKvTab(panel, "params:query", "Query parameter");
664
+ case "headers": return renderKvTab(panel, "headers", "Header");
665
+ case "body": return renderBodyTab(panel);
666
+ case "auth": return renderAuthTab(panel);
667
+ case "vars": return renderKvTab(panel, "vars", "Variable");
668
+ case "script": return renderReadOnlyRawTab(panel, "script:pre-request",
669
+ "// no pre-request script set");
670
+ case "tests": return renderReadOnlyRawTab(panel, "tests",
671
+ "// no tests defined");
672
+ case "docs": return renderRawTab(panel, "docs", "Markdown notes…");
673
+ }
674
+ } catch (e) {
675
+ panel.appendChild(ce("div", { class: "rhl-error" }, "UI error: " + e.message));
676
+ }
677
+ }
678
+
679
+ function renderKvTab(panel, blockName, placeholder) {
680
+ const block = ensureKvBlock(blockName);
681
+ const table = ce("table", { class: "rhl-kv-table" });
682
+ const thead = ce("thead", {}, ce("tr", {}, ce("th", {}, "Name"), ce("th", {}, "Value"), ce("th", {})));
683
+ const tbody = ce("tbody");
684
+ table.appendChild(thead);
685
+ table.appendChild(tbody);
686
+
687
+ block.pairs.forEach((pair, i) => {
688
+ const tr = ce("tr");
689
+ tr.appendChild(ce("td", {}, ce("input", {
690
+ value: pair[0] || "", placeholder,
691
+ oninput: (e) => { block.pairs[i][0] = e.target.value; }
692
+ })));
693
+ tr.appendChild(ce("td", {}, ce("input", {
694
+ value: pair[1] || "", placeholder: "value",
695
+ oninput: (e) => { block.pairs[i][1] = e.target.value; }
696
+ })));
697
+ tr.appendChild(ce("td", { class: "rhl-kv-table__del" }, ce("button", {
698
+ class: "rhl-iconbtn",
699
+ onclick: () => { block.pairs.splice(i, 1); onDocEdited(); renderPanel(); }
700
+ }, "×")));
701
+ tbody.appendChild(tr);
702
+ });
703
+
704
+ panel.appendChild(table);
705
+ panel.appendChild(ce("button", {
706
+ class: "rhl-btn rhl-btn--ghost",
707
+ onclick: () => { block.pairs.push(["", ""]); renderPanel(); }
708
+ }, "+ Add row"));
709
+ }
710
+
711
+ function renderBodyTab(panel) {
712
+ const currentKind = (getVerbPair("body")?.[1]) || "none";
713
+
714
+ const select = ce("select", {
715
+ class: "rhl-select",
716
+ onchange: (e) => { setVerbPair("body", e.target.value); renderPanel(); }
717
+ });
718
+ for (const k of BODY_KINDS_ALL) {
719
+ const opt = ce("option", { value: k }, k);
720
+ if (k === currentKind) opt.setAttribute("selected", "");
721
+ select.appendChild(opt);
722
+ }
723
+ const head = ce("div", { class: "rhl-tab-head" },
724
+ ce("label", { class: "rhl-tab-head__label" }, "Body type:"),
725
+ select
726
+ );
727
+ panel.appendChild(head);
728
+
729
+ if (currentKind === "none") {
730
+ panel.appendChild(ce("div", { class: "rhl-hint" }, "No body sent. Choose a type above."));
731
+ return;
732
+ }
733
+
734
+ if (BODY_KINDS_KV.includes(currentKind)) {
735
+ const block = ensureKvBlock(BODY_BLOCK[currentKind]);
736
+ const sub = ce("div");
737
+ panel.appendChild(sub);
738
+ renderKvTabInto(sub, block, "field");
739
+ return;
740
+ }
741
+
742
+ if (BODY_KINDS_RAW.includes(currentKind)) {
743
+ const block = ensureRawBlock(BODY_BLOCK[currentKind]);
744
+ const ta = ce("textarea", {
745
+ class: "rhl-textarea rhl-textarea--body",
746
+ spellcheck: "false",
747
+ oninput: (e) => { block.raw = e.target.value; }
748
+ });
749
+ ta.value = block.raw || "";
750
+ panel.appendChild(ta);
751
+ if (currentKind === "json") {
752
+ const btn = ce("button", {
753
+ class: "rhl-btn rhl-btn--ghost",
754
+ onclick: () => {
755
+ try {
756
+ const pretty = JSON.stringify(JSON.parse(ta.value), null, 2);
757
+ ta.value = pretty;
758
+ block.raw = pretty;
759
+ onDocEdited();
760
+ } catch (e) { showError("JSON: " + e.message); }
761
+ }
762
+ }, "Prettify");
763
+ panel.appendChild(btn);
764
+ }
765
+ return;
766
+ }
767
+
768
+ panel.appendChild(ce("div", { class: "rhl-hint" }, "Body type \"" + currentKind + "\" is preserved but has no inline editor in this version."));
769
+ }
770
+
771
+ function renderKvTabInto(container, block, placeholder) {
772
+ const table = ce("table", { class: "rhl-kv-table" });
773
+ const thead = ce("thead", {}, ce("tr", {}, ce("th", {}, "Name"), ce("th", {}, "Value"), ce("th", {})));
774
+ const tbody = ce("tbody");
775
+ table.appendChild(thead); table.appendChild(tbody);
776
+ block.pairs.forEach((pair, i) => {
777
+ const tr = ce("tr");
778
+ tr.appendChild(ce("td", {}, ce("input", {
779
+ value: pair[0] || "", placeholder,
780
+ oninput: (e) => { block.pairs[i][0] = e.target.value; }
781
+ })));
782
+ tr.appendChild(ce("td", {}, ce("input", {
783
+ value: pair[1] || "", placeholder: "value",
784
+ oninput: (e) => { block.pairs[i][1] = e.target.value; }
785
+ })));
786
+ tr.appendChild(ce("td", { class: "rhl-kv-table__del" }, ce("button", {
787
+ class: "rhl-iconbtn",
788
+ onclick: () => { block.pairs.splice(i, 1); onDocEdited(); renderPanel(); }
789
+ }, "×")));
790
+ tbody.appendChild(tr);
791
+ });
792
+ container.appendChild(table);
793
+ container.appendChild(ce("button", {
794
+ class: "rhl-btn rhl-btn--ghost",
795
+ onclick: () => { block.pairs.push(["", ""]); renderPanel(); }
796
+ }, "+ Add row"));
797
+ }
798
+
799
+ function renderAuthTab(panel) {
800
+ const kind = (getVerbPair("auth")?.[1]) || "none";
801
+ const select = ce("select", {
802
+ class: "rhl-select",
803
+ onchange: (e) => { setVerbPair("auth", e.target.value); renderPanel(); }
804
+ });
805
+ for (const k of ["none","bearer","basic","apikey","inherit"]) {
806
+ const opt = ce("option", { value: k }, k);
807
+ if (k === kind) opt.setAttribute("selected", "");
808
+ select.appendChild(opt);
809
+ }
810
+ panel.appendChild(ce("div", { class: "rhl-tab-head" }, ce("label", { class: "rhl-tab-head__label" }, "Auth:"), select));
811
+
812
+ if (kind === "bearer") {
813
+ const b = ensureKvBlock("auth:bearer");
814
+ panel.appendChild(formRow("Token", b, "token"));
815
+ } else if (kind === "basic") {
816
+ const b = ensureKvBlock("auth:basic");
817
+ panel.appendChild(formRow("Username", b, "username"));
818
+ panel.appendChild(formRow("Password", b, "password"));
819
+ } else if (kind === "apikey") {
820
+ const b = ensureKvBlock("auth:apikey");
821
+ panel.appendChild(formRow("Key", b, "key"));
822
+ panel.appendChild(formRow("Value", b, "value"));
823
+ panel.appendChild(formRow("Placement (header/queryparams)", b, "placement"));
824
+ } else {
825
+ panel.appendChild(ce("div", { class: "rhl-hint" }, "No auth headers added."));
826
+ }
827
+ }
828
+
829
+ function formRow(label, block, field) {
830
+ const pair = block.pairs.find(([k]) => k === field);
831
+ const input = ce("input", {
832
+ class: "rhl-input-wide",
833
+ value: pair?.[1] || "",
834
+ placeholder: field,
835
+ oninput: (e) => {
836
+ const p = block.pairs.find(([k]) => k === field);
837
+ if (p) p[1] = e.target.value;
838
+ else block.pairs.push([field, e.target.value]);
839
+ }
840
+ });
841
+ return ce("div", { class: "rhl-form-row" }, ce("label", {}, label), input);
842
+ }
843
+
844
+ function renderRawTab(panel, blockName, placeholder) {
845
+ const block = ensureRawBlock(blockName);
846
+ const ta = ce("textarea", {
847
+ class: "rhl-textarea",
848
+ placeholder,
849
+ spellcheck: "false",
850
+ oninput: (e) => { block.raw = e.target.value; }
851
+ });
852
+ ta.value = block.raw || "";
853
+ panel.appendChild(ta);
854
+ }
855
+
856
+ // Read-only variant for `script:*` and `tests` blocks: rails-http-lab
857
+ // persists them verbatim in the .bru file but never executes them — only
858
+ // Bruno desktop can run JS. Show the content faded so it's obvious.
859
+ function renderReadOnlyRawTab(panel, blockName, placeholder) {
860
+ const block = ensureRawBlock(blockName);
861
+ panel.appendChild(ce("div", { class: "rhl-readonly-banner" },
862
+ "Read-only — this block is preserved in the .bru file so Bruno desktop can execute it. " +
863
+ "rails-http-lab does not run JavaScript on the Rails server."
864
+ ));
865
+ const ta = ce("textarea", {
866
+ class: "rhl-textarea rhl-textarea--readonly",
867
+ placeholder,
868
+ spellcheck: "false",
869
+ readonly: true
870
+ });
871
+ ta.value = block.raw || "";
872
+ panel.appendChild(ta);
873
+ }
874
+
875
+ // ---------- Send / Save ----------
876
+ async function sendRequest() {
877
+ const session = activeSession();
878
+ if (!session) { showError("No request loaded."); return; }
879
+ syncVerbAndUrlIntoDoc();
880
+ setResponseState("Sending…", "", "", "");
881
+
882
+ // Read the select directly — state.envName can lag behind if the
883
+ // browser restored a selection on reload without firing 'change'.
884
+ const envName = $("#rhl-env-select").value || state.envName || "";
885
+ state.envName = envName;
886
+ try {
887
+ const resp = await api("POST", "/run", {
888
+ source: serializeDoc(session.doc),
889
+ environment: envName
890
+ });
891
+ session.response = resp;
892
+ session.touched = Date.now();
893
+ showResponse(resp);
894
+ schedulePersist();
895
+ } catch (e) {
896
+ session.response = { error: e.message };
897
+ showError(e.message);
898
+ schedulePersist();
899
+ }
900
+ }
901
+
902
+ async function saveRequest() {
903
+ const session = activeSession();
904
+ if (!session) return;
905
+ syncVerbAndUrlIntoDoc();
906
+ let path = session.path || state.currentKey;
907
+ if (!path) {
908
+ path = window.prompt("Save as (relative path, e.g. MyAPI/login.bru)");
909
+ if (!path) return;
910
+ if (!path.endsWith(".bru")) path += ".bru";
911
+ }
912
+ try {
913
+ await api("PUT", `/requests/${encodePath(path)}`, { source: serializeDoc(session.doc) });
914
+ if (path !== session.path) {
915
+ state.sessions.delete(session.path);
916
+ session.path = path;
917
+ state.sessions.set(path, session);
918
+ state.currentKey = path;
919
+ }
920
+ session.savedDoc = cloneDoc(session.doc); // now clean
921
+ session._wasDirty = false;
922
+ ensureExpanded(parentDir(path));
923
+ await loadTree();
924
+ updateSaveDirty();
925
+ schedulePersist();
926
+ flashStatus("Saved");
927
+ } catch (e) {
928
+ showError(e.message);
929
+ }
930
+ }
931
+
932
+ function syncVerbAndUrlIntoDoc() {
933
+ if (!currentDoc()) return;
934
+ setVerbName($("#rhl-verb").value.toLowerCase());
935
+ setVerbPair("url", $("#rhl-url").value);
936
+ }
937
+
938
+ // Fired on any edit inside the editor pane: refresh the dirty indicators and
939
+ // schedule a localStorage write. Re-renders the sidebar only when the dirty
940
+ // state of the active request flips (cheap; avoids reflow on every keystroke).
941
+ function onDocEdited() {
942
+ const s = activeSession();
943
+ if (!s) return;
944
+ s.touched = Date.now();
945
+ const dirty = isDirty(s);
946
+ updateSaveDirty(dirty);
947
+ if (dirty !== s._wasDirty) {
948
+ s._wasDirty = dirty;
949
+ if (state.tree) renderTree();
950
+ }
951
+ schedulePersist();
952
+ }
953
+
954
+ function updateSaveDirty(dirty) {
955
+ const s = activeSession();
956
+ const d = dirty != null ? dirty : (s && isDirty(s));
957
+ $("#rhl-save").classList.toggle("is-dirty", !!d);
958
+ }
959
+
960
+ // ---------- Response display ----------
961
+ function resetResponse() {
962
+ $("#rhl-response-status").textContent = "Ready";
963
+ $("#rhl-response-status").className = "";
964
+ $("#rhl-response-time").textContent = "";
965
+ $("#rhl-response-size").textContent = "";
966
+ $("#rhl-response-body").textContent = "Click Send to execute the request.";
967
+ $("#rhl-response-body").className = "rhl-response__body";
968
+ $("#rhl-response-pretty").textContent = "";
969
+ $("#rhl-response-pretty").className = "rhl-response__body";
970
+ $("#rhl-response-curl").textContent = "";
971
+ $("#rhl-response-curl").className = "rhl-response__body";
972
+ renderResponseHeaders(null);
973
+ setResponseTab("body");
974
+ }
975
+
976
+ function setResponseState(status, time, size, body) {
977
+ $("#rhl-response-status").textContent = status;
978
+ $("#rhl-response-time").textContent = time;
979
+ $("#rhl-response-size").textContent = size;
980
+ $("#rhl-response-body").textContent = body;
981
+ $("#rhl-response-body").className = "rhl-response__body";
982
+ $("#rhl-response-pretty").textContent = body;
983
+ $("#rhl-response-pretty").className = "rhl-response__body";
984
+ renderResponseHeaders(null);
985
+ }
986
+
987
+ function showResponse(resp) {
988
+ if (resp.error) { showError(resp.error, resp.request); return; }
989
+ const statusEl = $("#rhl-response-status");
990
+ const reason = STATUS_TEXT[resp.status] || "";
991
+ statusEl.textContent = reason ? `${resp.status} ${reason}` : `${resp.status}`;
992
+ statusEl.className = `rhl-pill rhl-pill--${statusFamily(resp.status)}`;
993
+ $("#rhl-response-time").textContent = resp.duration_ms != null ? `${resp.duration_ms} ms` : "";
994
+ $("#rhl-response-size").textContent = resp.size_bytes != null ? `${resp.size_bytes} B` : "";
995
+
996
+ if (resp.bodyTruncated) {
997
+ const note = "(response body too large to keep after reload — re-send to view it)";
998
+ $("#rhl-response-body").textContent = note;
999
+ $("#rhl-response-body").className = "rhl-response__body";
1000
+ $("#rhl-response-pretty").textContent = note;
1001
+ $("#rhl-response-pretty").className = "rhl-response__body";
1002
+ } else {
1003
+ const rawBody = resp.body || "";
1004
+ let prettyBody = rawBody;
1005
+ let isJson = false;
1006
+ try {
1007
+ prettyBody = JSON.stringify(JSON.parse(rawBody), null, 2);
1008
+ isJson = true;
1009
+ } catch (_) {}
1010
+
1011
+ $("#rhl-response-body").textContent = prettyBody;
1012
+ $("#rhl-response-body").className = "rhl-response__body";
1013
+
1014
+ const pretty = $("#rhl-response-pretty");
1015
+ pretty.className = "rhl-response__body";
1016
+ if (isJson) {
1017
+ pretty.innerHTML = highlightJson(prettyBody);
1018
+ } else {
1019
+ pretty.textContent = prettyBody;
1020
+ }
1021
+ }
1022
+
1023
+ renderResponseHeaders(resp.headers);
1024
+
1025
+ const curl = buildCurl(resp.request);
1026
+ $("#rhl-response-curl").textContent = curl || "(no request was sent — see the error)";
1027
+ $("#rhl-response-curl").className = "rhl-response__body";
1028
+ }
1029
+
1030
+ function showError(message, request) {
1031
+ $("#rhl-response-status").textContent = "ERROR";
1032
+ $("#rhl-response-status").className = "rhl-pill rhl-pill--err";
1033
+ $("#rhl-response-time").textContent = "";
1034
+ $("#rhl-response-size").textContent = "";
1035
+ $("#rhl-response-body").textContent = message;
1036
+ $("#rhl-response-body").className = "rhl-response__body rhl-response__body--err";
1037
+ $("#rhl-response-pretty").textContent = message;
1038
+ $("#rhl-response-pretty").className = "rhl-response__body rhl-response__body--err";
1039
+ const curl = request ? buildCurl(request) : "";
1040
+ $("#rhl-response-curl").textContent = curl || "(no request was sent — see the error)";
1041
+ $("#rhl-response-curl").className = "rhl-response__body";
1042
+ renderResponseHeaders(null);
1043
+ setResponseTab("body");
1044
+ }
1045
+
1046
+ // Lightweight JSON syntax highlighter — escapes HTML, then wraps tokens.
1047
+ // Strings, keys, numbers, booleans, null. No external deps.
1048
+ function highlightJson(text) {
1049
+ const escaped = text
1050
+ .replace(/&/g, "&amp;")
1051
+ .replace(/</g, "&lt;")
1052
+ .replace(/>/g, "&gt;");
1053
+ return escaped.replace(
1054
+ /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?)|\b(true|false)\b|\b(null)\b|(-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)/g,
1055
+ (m, str, bool, nul, num) => {
1056
+ if (str !== undefined) {
1057
+ const cls = /:\s*$/.test(str) ? "rhl-tok--key" : "rhl-tok--str";
1058
+ return `<span class="rhl-tok ${cls}">${str}</span>`;
1059
+ }
1060
+ if (bool) return `<span class="rhl-tok rhl-tok--bool">${bool}</span>`;
1061
+ if (nul) return `<span class="rhl-tok rhl-tok--null">${nul}</span>`;
1062
+ if (num) return `<span class="rhl-tok rhl-tok--num">${num}</span>`;
1063
+ return m;
1064
+ }
1065
+ );
1066
+ }
1067
+
1068
+ function statusFamily(s) {
1069
+ if (s >= 500) return "err";
1070
+ if (s >= 400) return "warn";
1071
+ if (s >= 200) return "ok";
1072
+ return "info";
1073
+ }
1074
+
1075
+ function renderResponseHeaders(headers) {
1076
+ const container = $("#rhl-response-headers");
1077
+ const badge = $("#rhl-response-headers-count");
1078
+ container.innerHTML = "";
1079
+
1080
+ const entries = headers && typeof headers === "object" ? Object.entries(headers) : [];
1081
+ if (entries.length === 0) {
1082
+ badge.hidden = true;
1083
+ container.appendChild(ce("div", { class: "rhl-hint" }, "No response headers."));
1084
+ return;
1085
+ }
1086
+
1087
+ badge.textContent = String(entries.length);
1088
+ badge.hidden = false;
1089
+
1090
+ const table = ce("table", { class: "rhl-headers-table" });
1091
+ table.appendChild(ce("thead", {}, ce("tr", {},
1092
+ ce("th", {}, "Name"), ce("th", {}, "Value"))));
1093
+ const tbody = ce("tbody");
1094
+ entries.forEach(([name, value]) => {
1095
+ const v = Array.isArray(value) ? value.join(", ") : String(value);
1096
+ tbody.appendChild(ce("tr", {},
1097
+ ce("td", { class: "rhl-headers-table__name" }, name),
1098
+ ce("td", { class: "rhl-headers-table__value" }, v)
1099
+ ));
1100
+ });
1101
+ table.appendChild(tbody);
1102
+ container.appendChild(table);
1103
+ }
1104
+
1105
+ function setResponseTab(tab) {
1106
+ const s = activeSession();
1107
+ if (s) s.responseTab = tab;
1108
+ document.querySelectorAll(".rhl-response-tab").forEach((el) => {
1109
+ el.classList.toggle("is-active", el.dataset.rtab === tab);
1110
+ });
1111
+ $("#rhl-response-pretty").hidden = tab !== "pretty";
1112
+ $("#rhl-response-body").hidden = tab !== "body";
1113
+ $("#rhl-response-headers").hidden = tab !== "headers";
1114
+ $("#rhl-response-curl").hidden = tab !== "curl";
1115
+ }
1116
+
1117
+ // Builds the cURL equivalent of the request the runner just sent.
1118
+ function buildCurl(req) {
1119
+ if (!req || !req.url) return "";
1120
+ const parts = ["curl"];
1121
+ const method = (req.method || "GET").toUpperCase();
1122
+ if (method !== "GET") parts.push(`-X ${method}`);
1123
+ parts.push(shellQuote(req.url));
1124
+ for (const [name, value] of Object.entries(req.headers || {})) {
1125
+ parts.push(`-H ${shellQuote(`${name}: ${value}`)}`);
1126
+ }
1127
+ if (req.body && String(req.body).length > 0) {
1128
+ parts.push(`--data-raw ${shellQuote(String(req.body))}`);
1129
+ }
1130
+ return parts.join(" \\\n ");
1131
+ }
1132
+
1133
+ // POSIX single-quote escape: wrap in '…', and escape inner ' as '\''.
1134
+ function shellQuote(s) {
1135
+ return "'" + String(s).replace(/'/g, "'\\''") + "'";
1136
+ }
1137
+
1138
+ function flashStatus(msg) {
1139
+ const prev = $("#rhl-response-status").textContent;
1140
+ $("#rhl-response-status").textContent = msg;
1141
+ setTimeout(() => { if ($("#rhl-response-status").textContent === msg) $("#rhl-response-status").textContent = prev; }, 1200);
1142
+ }
1143
+
1144
+ // ---------- Resizable splitter ----------
1145
+ function bindSplitter() {
1146
+ const splitter = $("#rhl-splitter");
1147
+ const panel = $("#rhl-panel");
1148
+ const response = $("#rhl-response");
1149
+ let dragging = false, startY = 0, startPanelPx = 0, startResponsePx = 0;
1150
+
1151
+ splitter.addEventListener("mousedown", (e) => {
1152
+ dragging = true; startY = e.clientY;
1153
+ startPanelPx = panel.getBoundingClientRect().height;
1154
+ startResponsePx = response.getBoundingClientRect().height;
1155
+ document.body.style.cursor = "row-resize";
1156
+ e.preventDefault();
1157
+ });
1158
+ window.addEventListener("mousemove", (e) => {
1159
+ if (!dragging) return;
1160
+ const dy = e.clientY - startY;
1161
+ const newPanel = Math.max(80, startPanelPx + dy);
1162
+ const newResponse = Math.max(80, startResponsePx - dy);
1163
+ panel.style.flex = "0 0 " + newPanel + "px";
1164
+ response.style.flex = "0 0 " + newResponse + "px";
1165
+ });
1166
+ window.addEventListener("mouseup", () => {
1167
+ if (!dragging) return;
1168
+ dragging = false;
1169
+ document.body.style.cursor = "";
1170
+ });
1171
+ }
1172
+
1173
+ // ---------- Environment editor (modal) ----------
1174
+ let envState = { name: null, vars: [] };
1175
+
1176
+ async function openEnvModal() {
1177
+ const modal = $("#rhl-env-modal");
1178
+ modal.hidden = false;
1179
+ await renderEnvList();
1180
+ }
1181
+ function closeEnvModal() { $("#rhl-env-modal").hidden = true; }
1182
+
1183
+ async function renderEnvList() {
1184
+ const list = $("#rhl-envs-list");
1185
+ list.innerHTML = "";
1186
+ const data = await api("GET", "/environments");
1187
+ for (const env of (data.environments || [])) {
1188
+ const row = ce("div", { class: "rhl-envs-list__item" }, env.name);
1189
+ row.addEventListener("click", () => loadEnv(env.name));
1190
+ list.appendChild(row);
1191
+ }
1192
+ list.appendChild(ce("button", {
1193
+ class: "rhl-btn rhl-btn--ghost",
1194
+ onclick: async () => {
1195
+ const name = window.prompt("Environment name (e.g. Staging)");
1196
+ if (!name) return;
1197
+ await api("PUT", `/environments/${encodeURIComponent(name)}`, { vars: [] });
1198
+ await renderEnvList();
1199
+ loadEnv(name);
1200
+ }
1201
+ }, "+ New environment"));
1202
+ }
1203
+
1204
+ async function loadEnv(name) {
1205
+ const data = await api("GET", `/environments/${encodeURIComponent(name)}`);
1206
+ envState.name = name;
1207
+ envState.vars = (data.vars || []).map(([k, v]) => [k, v]);
1208
+ renderEnvEditor();
1209
+ }
1210
+
1211
+ function renderEnvEditor() {
1212
+ const editor = $("#rhl-envs-editor");
1213
+ editor.innerHTML = "";
1214
+
1215
+ if (!envState.name) {
1216
+ editor.appendChild(ce("em", {}, "Select an environment on the left."));
1217
+ return;
1218
+ }
1219
+
1220
+ editor.appendChild(ce("h3", {}, envState.name));
1221
+
1222
+ const table = ce("table", { class: "rhl-kv-table" });
1223
+ table.appendChild(ce("thead", {}, ce("tr", {},
1224
+ ce("th", {}, "Name"), ce("th", {}, "Value"), ce("th", {}))));
1225
+ const tbody = ce("tbody");
1226
+ envState.vars.forEach((pair, i) => {
1227
+ const tr = ce("tr");
1228
+ tr.appendChild(ce("td", {}, ce("input", {
1229
+ value: pair[0] || "", placeholder: "name",
1230
+ oninput: (e) => { envState.vars[i][0] = e.target.value; }
1231
+ })));
1232
+ tr.appendChild(ce("td", {}, ce("input", {
1233
+ value: pair[1] || "", placeholder: "value",
1234
+ oninput: (e) => { envState.vars[i][1] = e.target.value; }
1235
+ })));
1236
+ tr.appendChild(ce("td", { class: "rhl-kv-table__del" }, ce("button", {
1237
+ class: "rhl-iconbtn",
1238
+ onclick: () => { envState.vars.splice(i, 1); renderEnvEditor(); }
1239
+ }, "×")));
1240
+ tbody.appendChild(tr);
1241
+ });
1242
+ table.appendChild(tbody);
1243
+ editor.appendChild(table);
1244
+
1245
+ editor.appendChild(ce("button", {
1246
+ class: "rhl-btn rhl-btn--ghost",
1247
+ onclick: () => { envState.vars.push(["", ""]); renderEnvEditor(); }
1248
+ }, "+ Add variable"));
1249
+
1250
+ editor.appendChild(ce("div", { class: "rhl-envs-editor__footer" },
1251
+ ce("button", {
1252
+ class: "rhl-btn rhl-btn--send",
1253
+ onclick: async () => {
1254
+ await api("PUT", `/environments/${encodeURIComponent(envState.name)}`, {
1255
+ vars: envState.vars.map(([k, v]) => ({ key: k, value: v }))
1256
+ });
1257
+ await loadTree();
1258
+ flashStatus("Env saved");
1259
+ $("#rhl-envs-editor h3").textContent = envState.name + " ✓";
1260
+ }
1261
+ }, "Save"),
1262
+ ce("button", {
1263
+ class: "rhl-btn",
1264
+ onclick: closeEnvModal
1265
+ }, "Close")
1266
+ ));
1267
+ }
1268
+
1269
+ // ---------- Wiring ----------
1270
+ function bindActions() {
1271
+ $("#rhl-tabs").addEventListener("click", (e) => {
1272
+ const t = e.target.closest(".rhl-tab");
1273
+ if (t) setActiveTab(t.dataset.tab);
1274
+ });
1275
+
1276
+ $("#rhl-response-tabs").addEventListener("click", (e) => {
1277
+ const t = e.target.closest(".rhl-response-tab");
1278
+ if (t) setResponseTab(t.dataset.rtab);
1279
+ });
1280
+
1281
+ $("#rhl-send").addEventListener("click", sendRequest);
1282
+ $("#rhl-save").addEventListener("click", saveRequest);
1283
+
1284
+ $("#rhl-env-select").addEventListener("change", (e) => { state.envName = e.target.value; schedulePersist(); });
1285
+ $("#rhl-env-edit").addEventListener("click", openEnvModal);
1286
+ $("#rhl-env-modal-close").addEventListener("click", closeEnvModal);
1287
+ $("#rhl-env-modal").addEventListener("click", (e) => {
1288
+ if (e.target.dataset.close === "1") closeEnvModal();
1289
+ });
1290
+
1291
+ const clearBtn = $("#rhl-clear-cache");
1292
+ if (clearBtn) clearBtn.addEventListener("click", clearCache);
1293
+
1294
+ $("#rhl-new-collection").addEventListener("click", promptNewCollection);
1295
+
1296
+ // Any edit inside the editor pane → refresh dirty indicators + persist.
1297
+ const editor = $("#rhl-editor");
1298
+ editor.addEventListener("input", onDocEdited);
1299
+ editor.addEventListener("change", onDocEdited);
1300
+
1301
+ // Keep verb/url synced into the active doc as the user types.
1302
+ $("#rhl-verb").addEventListener("change", () => { if (currentDoc()) setVerbName($("#rhl-verb").value.toLowerCase()); });
1303
+ $("#rhl-url").addEventListener("input", () => { if (currentDoc()) setVerbPair("url", $("#rhl-url").value); });
1304
+ }
1305
+
1306
+ document.addEventListener("DOMContentLoaded", async () => {
1307
+ bindActions();
1308
+ bindSplitter();
1309
+ hydrateSessions();
1310
+ try {
1311
+ await loadTree();
1312
+ pruneSessions();
1313
+ restoreLastSession();
1314
+ } catch (e) {
1315
+ $("#rhl-tree").textContent = "Error: " + e.message;
1316
+ }
1317
+ });
1318
+ })();