4bnode 4.2.0 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,74 +1,74 @@
1
1
  import React from "react";
2
- import { html, Icons } from "../lib.js";
2
+ import { html, Icons, NAV, MOD_KEY } from "../lib.js";
3
3
  import { useApp } from "../context.js";
4
4
 
5
5
  export function Sidebar() {
6
- const { page, setPage, data } = useApp();
6
+ const {
7
+ page, setPage, data, status, health,
8
+ collapsed, toggleCollapsed, mobileNav, setMobileNav, openPalette,
9
+ } = useApp();
7
10
  const dbOk = data.mongoConfigured;
11
+ const online = !!health;
8
12
 
9
- const navItems = [
10
- {
11
- id: "dashboard",
12
- label: "Dashboard",
13
- icon: Icons.dashboard,
14
- section: "Overview",
15
- },
16
- // AI Builder — temporarily hidden from the menu (re-enable by uncommenting)
17
- // { id: "ai-builder", label: "AI Builder", icon: Icons.ai },
18
- {
19
- id: "database",
20
- label: "Database",
21
- icon: Icons.database,
22
- section: "Setup",
23
- },
24
- {
25
- id: "schemas",
26
- label: "Schemas",
27
- icon: Icons.schema,
28
- locked: !dbOk,
29
- lockMsg: "Requires database",
30
- },
31
- { id: "routes", label: "API Routes", icon: Icons.route },
32
- { id: "docs", label: "API Docs", icon: Icons.docs },
33
- { id: "api-tester", label: "API Tester", icon: Icons.apiTester },
34
- {
35
- id: "security",
36
- label: "Security",
37
- icon: Icons.shield,
38
- section: "Features",
39
- },
40
- { id: "realtime", label: "Real-time", icon: Icons.realtime },
41
- { id: "serialport", label: "SerialPort", icon: Icons.serial },
42
- { id: "mail", label: "Email", icon: Icons.mail },
43
- { id: "discovery", label: "Discovery", icon: Icons.network },
44
- { id: "logs", label: "Logs", icon: Icons.logs, section: "Settings" },
45
- { id: "env", label: "Environment", icon: Icons.env },
46
- ];
13
+ function go(id) {
14
+ setPage(id);
15
+ setMobileNav(false);
16
+ }
47
17
 
48
18
  let currentSection = null;
49
- return html`<div className="sidebar">
50
- <div className="sidebar-brand">
51
- <pre>${`██╗ ██╗██████╗ ███╗ ██╗ ██████╗ ██████╗ ███████╗\n██║ ██║██╔══██╗████╗ ██║██╔═══██╗██╔══██╗██╔════╝\n███████║██████╔╝██╔██╗ ██║██║ ██║██║ ██║█████╗\n╚════██║██╔══██╗██║╚██╗██║██║ ██║██║ ██║██╔══╝\n ██║██████╔╝██║ ╚████║╚██████╔╝██████╔╝███████╗\n ╚═╝╚═════╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝`}</pre>
52
- </div>
53
- ${navItems.map((item) => {
54
- const sectionHeader =
55
- item.section && item.section !== currentSection
56
- ? ((currentSection = item.section),
57
- html`<div className="sidebar-section">${item.section}</div>`)
58
- : null;
59
- return html`<${React.Fragment} key=${item.id}>
60
- ${sectionHeader}
61
- <div
62
- className=${`sidebar-item ${page === item.id ? "active" : ""} ${item.locked ? "locked" : ""}`}
63
- onClick=${() => !item.locked && setPage(item.id)}
64
- title=${item.locked ? item.lockMsg : item.label}
65
- >
66
- <span className="sidebar-icon">${item.icon}</span>
67
- ${item.label}
68
- ${item.locked &&
69
- html`<span className="lock-icon">${Icons.lock}</span>`}
19
+ return html`<${React.Fragment}>
20
+ ${mobileNav && html`<div className="sidebar-scrim" onClick=${() => setMobileNav(false)} />`}
21
+ <aside className=${`sidebar${collapsed ? " collapsed" : ""}${mobileNav ? " open" : ""}`}>
22
+ <div className="sidebar-brand">
23
+ <div className="brand-mark">4b</div>
24
+ <div className="brand-text">
25
+ <span className="brand-name">4bnode</span>
26
+ <span className="brand-app" title=${status.appName || ""}>${status.appName || "project"}</span>
70
27
  </div>
71
- <//>`;
72
- })}
73
- </div>`;
28
+ </div>
29
+
30
+ <button className="sidebar-search" onClick=${openPalette} title="Search (${MOD_KEY}K)">
31
+ <span className="sidebar-icon">${Icons.search}</span>
32
+ <span className="sidebar-label">Search…</span>
33
+ <kbd className="sidebar-kbd">${MOD_KEY}K</kbd>
34
+ </button>
35
+
36
+ <nav className="sidebar-nav">
37
+ ${NAV.map((item) => {
38
+ const locked = item.needsDb && !dbOk;
39
+ const sectionHeader =
40
+ item.section !== currentSection
41
+ ? ((currentSection = item.section),
42
+ html`<div className="sidebar-section"><span>${item.section}</span></div>`)
43
+ : null;
44
+ return html`<${React.Fragment} key=${item.id}>
45
+ ${sectionHeader}
46
+ <button
47
+ className=${`sidebar-item${page === item.id ? " active" : ""}${locked ? " locked" : ""}`}
48
+ onClick=${() => !locked && go(item.id)}
49
+ title=${locked ? "Requires database" : item.label}
50
+ aria-current=${page === item.id ? "page" : undefined}
51
+ >
52
+ <span className="sidebar-icon">${item.icon}</span>
53
+ <span className="sidebar-label">${item.label}</span>
54
+ ${locked && html`<span className="lock-icon">${Icons.lock}</span>`}
55
+ </button>
56
+ <//>`;
57
+ })}
58
+ </nav>
59
+
60
+ <div className="sidebar-footer">
61
+ <div className="sidebar-status" title=${online ? "Server online" : "Server unreachable"}>
62
+ <span className=${`status-dot ${online ? "on" : "off"}`} />
63
+ <span className="sidebar-label">
64
+ ${online ? `Online · ${health.node}` : "Connecting…"}
65
+ </span>
66
+ </div>
67
+ <button className="sidebar-collapse" onClick=${toggleCollapsed}
68
+ title=${collapsed ? "Expand sidebar ([)" : "Collapse sidebar ([)"}>
69
+ ${collapsed ? Icons.expand : Icons.collapse}
70
+ </button>
71
+ </div>
72
+ </aside>
73
+ <//>`;
74
74
  }
@@ -24,6 +24,15 @@ import {
24
24
  LuBookOpen,
25
25
  LuMail,
26
26
  LuWifi,
27
+ LuSearch,
28
+ LuPanelLeftClose,
29
+ LuPanelLeftOpen,
30
+ LuMenu,
31
+ LuExternalLink,
32
+ LuRefreshCw,
33
+ LuKeyboard,
34
+ LuCommand,
35
+ LuX,
27
36
  } from "react-icons/lu";
28
37
 
29
38
  export const html = htm.bind(React.createElement);
@@ -96,8 +105,75 @@ export const Icons = {
96
105
  docs: html`<${LuBookOpen} size=${16} />`,
97
106
  mail: html`<${LuMail} size=${16} />`,
98
107
  network: html`<${LuWifi} size=${16} />`,
108
+ search: html`<${LuSearch} size=${15} />`,
109
+ collapse: html`<${LuPanelLeftClose} size=${16} />`,
110
+ expand: html`<${LuPanelLeftOpen} size=${16} />`,
111
+ menu: html`<${LuMenu} size=${18} />`,
112
+ external: html`<${LuExternalLink} size=${14} />`,
113
+ refresh: html`<${LuRefreshCw} size=${14} />`,
114
+ keyboard: html`<${LuKeyboard} size=${16} />`,
115
+ command: html`<${LuCommand} size=${16} />`,
116
+ close: html`<${LuX} size=${16} />`,
99
117
  };
100
118
 
119
+ // Single source of truth for navigation — used by the Sidebar and the ⌘K
120
+ // command palette. `key` is the second key of the `g <key>` jump shortcut.
121
+ export const NAV = [
122
+ { id: "dashboard", label: "Dashboard", icon: Icons.dashboard, section: "Overview", key: "d" },
123
+ // AI Builder — temporarily hidden from the menu (re-enable by uncommenting)
124
+ // { id: "ai-builder", label: "AI Builder", icon: Icons.ai, section: "Overview" },
125
+ { id: "database", label: "Database", icon: Icons.database, section: "Build", key: "b" },
126
+ { id: "schemas", label: "Schemas", icon: Icons.schema, section: "Build", key: "s", needsDb: true },
127
+ { id: "routes", label: "API Routes", icon: Icons.route, section: "Build", key: "r" },
128
+ { id: "docs", label: "API Docs", icon: Icons.docs, section: "Build", key: "o" },
129
+ { id: "api-tester", label: "API Tester", icon: Icons.apiTester, section: "Build", key: "t" },
130
+ { id: "security", label: "Security", icon: Icons.shield, section: "Features", key: "x" },
131
+ { id: "realtime", label: "Real-time", icon: Icons.realtime, section: "Features", key: "w" },
132
+ { id: "serialport", label: "SerialPort", icon: Icons.serial, section: "Features", key: "p" },
133
+ { id: "mail", label: "Email", icon: Icons.mail, section: "Features", key: "m" },
134
+ { id: "discovery", label: "Discovery", icon: Icons.network, section: "Features", key: "n" },
135
+ { id: "logs", label: "Logs", icon: Icons.logs, section: "System", key: "l" },
136
+ { id: "env", label: "Environment", icon: Icons.env, section: "System", key: "e" },
137
+ ];
138
+
139
+ export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
140
+ export const MOD_KEY = IS_MAC ? "⌘" : "Ctrl";
141
+
142
+ export function formatBytes(n) {
143
+ if (n == null) return "—";
144
+ const u = ["B", "KB", "MB", "GB"];
145
+ let i = 0;
146
+ while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
147
+ return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
148
+ }
149
+
150
+ export function formatDuration(sec) {
151
+ if (sec == null) return "—";
152
+ if (sec < 60) return `${sec}s`;
153
+ const m = Math.floor(sec / 60);
154
+ if (m < 60) return `${m}m ${sec % 60}s`;
155
+ const h = Math.floor(m / 60);
156
+ if (h < 24) return `${h}h ${m % 60}m`;
157
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
158
+ }
159
+
160
+ export function timeAgo(ts) {
161
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
162
+ if (s < 5) return "just now";
163
+ if (s < 60) return `${s}s ago`;
164
+ const m = Math.floor(s / 60);
165
+ if (m < 60) return `${m}m ago`;
166
+ const h = Math.floor(m / 60);
167
+ return h < 24 ? `${h}h ago` : `${Math.floor(h / 24)}d ago`;
168
+ }
169
+
170
+ // True when a key event comes from a text field — global shortcuts skip these.
171
+ export function isTypingTarget(el) {
172
+ if (!el) return false;
173
+ const tag = el.tagName;
174
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
175
+ }
176
+
101
177
  export const PAGE_TITLES = {
102
178
  dashboard: "Dashboard",
103
179
  "ai-builder": "AI Builder",
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react";
2
2
  import { motion, AnimatePresence } from "framer-motion";
3
3
  import { io as socketIoConnect } from "socket.io-client";
4
4
  import { LuChevronLeft, LuCircleDot, LuCopy, LuImport, LuLoader, LuPencil, LuPlugZap, LuPlus, LuRadio, LuSave, LuSend, LuShare2, LuTrash2, LuWifi, LuWifiOff } from "react-icons/lu";
5
- import { html, api, Icons, copyToClipboard } from "../lib.js";
5
+ import { html, api, Icons, copyToClipboard, timeAgo } from "../lib.js";
6
6
  import { useApp } from "../context.js";
7
7
  import { Modal } from "../components/Modal.js";
8
8
 
@@ -135,7 +135,7 @@ function SocketIOTester({ selectedSaved, isSaved, loadedTest, onSaveTest, onSave
135
135
  </button>`}
136
136
  </div>
137
137
 
138
- <div style=${{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
138
+ <div className="grid-2" style=${{ marginBottom: '16px' }}>
139
139
  <!-- Emit -->
140
140
  <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
141
141
  <div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
@@ -430,12 +430,12 @@ export function SavedResponseDetail({ resp, parentTest, onBack, onUpdate }) {
430
430
  <div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
431
431
  <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Request</span>
432
432
  <span className=${`route-method ${(parentTest.method || 'GET').toLowerCase()}`} style=${{ fontSize: '10px', padding: '1px 6px' }}>${parentTest.method || 'GET'}</span>
433
- <span style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px', color: 'var(--text-primary)' }}>${parentTest.url}</span>
433
+ <span style=${{ fontFamily: "var(--mono)", fontSize: '12px', color: 'var(--text-primary)' }}>${parentTest.url}</span>
434
434
  </div>
435
435
  ${(editReqBody?.trim() || editing) && html`<div style=${{ padding: editing ? '0' : '10px 16px' }}>
436
436
  ${!editing && html`<div>
437
437
  <div style=${{ padding: '0 0 6px', fontSize: '10px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.3px', color: 'var(--text-muted)' }}>Body Sent</div>
438
- <pre style=${{ margin: 0, fontFamily: "'SF Mono', monospace", fontSize: '12px', lineHeight: '1.5', color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>${editReqBody}</pre>
438
+ <pre style=${{ margin: 0, fontFamily: "var(--mono)", fontSize: '12px', lineHeight: '1.5', color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>${editReqBody}</pre>
439
439
  </div>`}
440
440
  ${editing && html`<textarea className="api-tester-body-editor" value=${editReqBody}
441
441
  onInput=${(e) => setEditReqBody(e.target.value)} spellcheck=${false}
@@ -616,28 +616,67 @@ export function ApiTesterPage() {
616
616
  // ── Request history (persisted per-browser) ──
617
617
  const [history, setHistory] = useState([]);
618
618
  const [showHistory, setShowHistory] = useState(false);
619
+ const [historyQuery, setHistoryQuery] = useState('');
619
620
  useEffect(() => {
620
- try { const h = JSON.parse(sessionStorage.getItem('_4bnode_history') || '[]'); if (Array.isArray(h)) setHistory(h); } catch {}
621
+ try { const h = JSON.parse(localStorage.getItem('_4bnode_history') || '[]'); if (Array.isArray(h)) setHistory(h); } catch {}
621
622
  }, []);
623
+ // Each entry stores the full request (not the response) so it can be replayed.
624
+ // File contents are stripped to keep storage small.
622
625
  function pushHistory(entry) {
626
+ const req = {
627
+ headers: headers.map(h => ({ ...h })),
628
+ body,
629
+ bodyType,
630
+ params: params.map(p => ({ ...p })),
631
+ urlParams: urlParams.map(p => ({ ...p })),
632
+ formFields: formFields.map(({ fileData, ...f }) => f),
633
+ };
623
634
  setHistory(prev => {
624
- const next = [entry, ...prev].slice(0, 50);
625
- try { sessionStorage.setItem('_4bnode_history', JSON.stringify(next)); } catch {}
635
+ const next = [{ ...entry, req }, ...prev].slice(0, 100);
636
+ try { localStorage.setItem('_4bnode_history', JSON.stringify(next)); } catch {}
626
637
  return next;
627
638
  });
628
639
  }
629
640
  function clearHistory() {
630
641
  setHistory([]);
631
- try { sessionStorage.removeItem('_4bnode_history'); } catch {}
642
+ try { localStorage.removeItem('_4bnode_history'); } catch {}
643
+ }
644
+ function historySnap(h) {
645
+ return { method: h.method, url: h.url, ...(h.req || {}), response: null, activeTab: 'body' };
632
646
  }
633
647
  function loadHistory(h) {
634
- setMethod(h.method);
635
- setUrl(h.url);
636
- setResponse(null);
637
- setSelectedSaved(null);
638
- setSelectedResponse(null);
648
+ if (h.req) loadSnap(historySnap(h));
649
+ else { setMethod(h.method); setUrl(h.url); setResponse(null); setSelectedSaved(null); setSelectedResponse(null); }
650
+ setShowHistory(false);
651
+ }
652
+ function openHistoryInNewTab(h) {
653
+ const snap = captureSnap();
654
+ const id = Math.max(0, ...tabs.map(t => t.id)) + 1;
655
+ setTabs(prev => [...prev.map(t => t.id === activeTabId ? { ...t, snap } : t), { id, snap: historySnap(h) }]);
656
+ loadSnap(historySnap(h));
657
+ setActiveTabId(id);
639
658
  setShowHistory(false);
640
659
  }
660
+ const filteredHistory = historyQuery.trim()
661
+ ? history.filter(h => (h.method + ' ' + h.url + ' ' + h.status).toLowerCase().includes(historyQuery.trim().toLowerCase()))
662
+ : history;
663
+
664
+ // Keyboard shortcuts (REST mode): ⌘/Ctrl+Enter send, Alt+N new tab,
665
+ // Alt+W close tab, Alt+H history. A ref keeps the handler on fresh state.
666
+ const shortcutRef = useRef(null);
667
+ shortcutRef.current = (e) => {
668
+ if (testerMode !== 'rest' || document.querySelector('.palette-overlay, .modal-overlay')) return;
669
+ const k = e.key.toLowerCase();
670
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); if (!loading && url.trim()) sendRequest(); }
671
+ else if (e.altKey && (k === 'n' || e.code === 'KeyN')) { e.preventDefault(); newTab(); }
672
+ else if (e.altKey && (k === 'w' || e.code === 'KeyW')) { e.preventDefault(); closeTab(activeTabId); }
673
+ else if (e.altKey && (k === 'h' || e.code === 'KeyH')) { e.preventDefault(); setShowHistory(v => !v); setShowVars(false); }
674
+ };
675
+ useEffect(() => {
676
+ const onKey = (e) => shortcutRef.current && shortcutRef.current(e);
677
+ window.addEventListener('keydown', onKey);
678
+ return () => window.removeEventListener('keydown', onKey);
679
+ }, []);
641
680
 
642
681
  // Latest-committed mirrors of tests/responses. Persistence reads these so a PUT
643
682
  // always sends the LATEST tests AND responses — never a stale half from an older
@@ -909,6 +948,15 @@ export function ApiTesterPage() {
909
948
  hdrs[auth.apiKeyKey.trim()] = subst(auth.apiKeyValue);
910
949
  }
911
950
  const finalUrl = subst(buildDisplayUrl());
951
+ // App-wide API key gate: send {{apiKey}} as x-api-key, but only to this
952
+ // machine, so the key never leaks to an external URL.
953
+ const apiKeyVar = variables.find(v => v.key === 'apiKey' && v.value);
954
+ if (apiKeyVar && !Object.keys(hdrs).some(h => h.toLowerCase() === 'x-api-key')) {
955
+ try {
956
+ const host = new URL(finalUrl).hostname;
957
+ if ([window.location.hostname, 'localhost', '127.0.0.1', '[::1]'].includes(host)) hdrs['x-api-key'] = apiKeyVar.value;
958
+ } catch {}
959
+ }
912
960
  // For base64 send-as, convert form field value into body JSON
913
961
  let sendBody = undefined;
914
962
  let sendBodyType = bodyType;
@@ -1304,7 +1352,7 @@ export function ApiTesterPage() {
1304
1352
  const displayUrl = buildDisplayUrl();
1305
1353
 
1306
1354
  const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
1307
- const methodColors = { GET: 'var(--success)', POST: '#f59e0b', PUT: '#3b82f6', PATCH: '#8b5cf6', DELETE: 'var(--danger)' };
1355
+ const methodColors = { GET: 'var(--success)', POST: 'var(--accent-light)', PUT: 'var(--warning)', PATCH: '#b48cff', DELETE: 'var(--error)' };
1308
1356
 
1309
1357
  const modeTabs = [
1310
1358
  { id: 'rest', label: 'REST API', icon: LuSend },
@@ -1364,8 +1412,8 @@ export function ApiTesterPage() {
1364
1412
  </span>`}
1365
1413
  ${resps.length === 0 && html`<span style=${{ width: '14px', flexShrink: 0 }} />`}
1366
1414
  ${testType === 'rest' && html`<span className=${`route-method ${(test.method || 'GET').toLowerCase()}`} style=${{ fontSize: '9px', padding: '1px 5px' }}>${test.method || 'GET'}</span>`}
1367
- ${testType === 'socketio' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "'SF Mono', monospace", ...typeBadgeStyles.socketio }}>SIO</span>`}
1368
- ${testType === 'websocket' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "'SF Mono', monospace", ...typeBadgeStyles.websocket }}>WS</span>`}
1415
+ ${testType === 'socketio' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "var(--mono)", ...typeBadgeStyles.socketio }}>SIO</span>`}
1416
+ ${testType === 'websocket' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "var(--mono)", ...typeBadgeStyles.websocket }}>WS</span>`}
1369
1417
  <span style=${{ fontSize: '12px', fontWeight: 500, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${test.name}</span>
1370
1418
  ${resps.length > 0 && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '8px', background: 'var(--accent-glow)', color: 'var(--accent-light)', fontWeight: 600 }}>${resps.length}</span>`}
1371
1419
  <button className="btn btn-ghost" style=${{ padding: '2px', opacity: 0.4 }}
@@ -1385,7 +1433,7 @@ export function ApiTesterPage() {
1385
1433
  onClick=${() => viewSavedResponse(test.id, r.id)}>
1386
1434
  <div className="flex items-center gap-2">
1387
1435
  ${isSocketChild
1388
- ? html`<span style=${{ fontSize: '10px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "'SF Mono', monospace",
1436
+ ? html`<span style=${{ fontSize: '10px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "var(--mono)",
1389
1437
  background: r.dir === 'received' ? 'rgba(52,211,153,0.15)' : r.dir === 'sent' ? 'rgba(245,158,11,0.15)' : 'rgba(148,163,184,0.15)',
1390
1438
  color: r.dir === 'received' ? '#34d399' : r.dir === 'sent' ? '#f59e0b' : '#94a3b8' }}>
1391
1439
  ${r.dir === 'received' ? '↓' : r.dir === 'sent' ? '↑' : '●'}
@@ -1432,20 +1480,24 @@ export function ApiTesterPage() {
1432
1480
 
1433
1481
  ${testerMode === 'rest' && html`<div>
1434
1482
  <!-- Request tabs -->
1435
- <div style=${{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '10px', flexWrap: 'wrap' }}>
1436
- ${tabs.map(t => { const active = t.id === activeTabId; return html`<div key=${t.id}
1437
- onClick=${() => switchTab(t.id)}
1438
- style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', cursor: 'pointer',
1439
- border: '1px solid ' + (active ? 'var(--accent)' : 'var(--border)'),
1440
- background: active ? 'var(--accent-glow)' : 'transparent',
1441
- color: active ? 'var(--accent-light)' : 'var(--text-secondary)',
1442
- borderRadius: '6px', padding: '5px 10px', maxWidth: '220px', fontSize: '12px' }}>
1443
- <span style=${{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${tabLabel(t)}</span>
1444
- ${tabs.length > 1 && html`<span onClick=${(e) => { e.stopPropagation(); closeTab(t.id); }}
1445
- style=${{ cursor: 'pointer', fontSize: '15px', lineHeight: 1, opacity: 0.6 }}>×</span>`}
1446
- </div>`; })}
1447
- <button onClick=${newTab} title="New request tab"
1448
- style=${{ border: '1px solid var(--border)', background: 'transparent', color: 'var(--text-secondary)', borderRadius: '6px', padding: '4px 11px', cursor: 'pointer', fontSize: '16px', lineHeight: 1 }}>+</button>
1483
+ <div className="req-tabs" role="tablist">
1484
+ ${tabs.map(t => {
1485
+ const active = t.id === activeTabId;
1486
+ const s = active ? { method, url } : (t.snap || {});
1487
+ const label = tabLabel(t);
1488
+ const m = s.url ? (s.method || 'GET') : null;
1489
+ return html`<div key=${t.id} role="tab" aria-selected=${active}
1490
+ className=${`req-tab${active ? ' active' : ''}`}
1491
+ onClick=${() => switchTab(t.id)}
1492
+ onAuxClick=${(e) => { if (e.button === 1) { e.preventDefault(); closeTab(t.id); } }}
1493
+ title=${s.url || 'New Request'}>
1494
+ ${m && html`<span className="req-tab-method" style=${{ color: methodColors[m] || 'var(--text-secondary)' }}>${m}</span>`}
1495
+ <span className="req-tab-label">${m ? label.slice(m.length + 1) : label}</span>
1496
+ ${tabs.length > 1 && html`<button className="req-tab-close" aria-label="Close tab"
1497
+ onClick=${(e) => { e.stopPropagation(); closeTab(t.id); }}>×</button>`}
1498
+ </div>`;
1499
+ })}
1500
+ <button className="req-tab-new" onClick=${newTab} title="New request tab (Alt+N)"><${LuPlus} size=${14} /></button>
1449
1501
  </div>
1450
1502
 
1451
1503
  <!-- Request bar -->
@@ -1464,7 +1516,7 @@ export function ApiTesterPage() {
1464
1516
  onInput=${(e) => setUrl(e.target.value)}
1465
1517
  onKeyDown=${(e) => { if (e.key === 'Enter') sendRequest(); }}
1466
1518
  />
1467
- <button className="api-tester-send" onClick=${sendRequest} disabled=${loading || !url.trim()}>
1519
+ <button className="api-tester-send" onClick=${sendRequest} disabled=${loading || !url.trim()} title="Send (Ctrl/⌘+Enter)">
1468
1520
  ${loading ? html`<${LuLoader} size=${14} className="spin" />` : html`<${LuSend} size=${14} />`}
1469
1521
  Send
1470
1522
  </button>
@@ -1474,7 +1526,7 @@ export function ApiTesterPage() {
1474
1526
  Variables${variables.filter(v => v.key?.trim()).length ? ` (${variables.filter(v => v.key?.trim()).length})` : ''}
1475
1527
  </button>
1476
1528
  <button className="api-tester-send" onClick=${() => { setShowHistory(h => !h); setShowVars(false); }}
1477
- title="Request history"
1529
+ title="Request history (Alt+H)"
1478
1530
  style=${{ background: showHistory ? 'var(--accent-glow)' : 'transparent', border: '1px solid var(--border)', color: showHistory ? 'var(--accent-light)' : 'var(--text-primary)' }}>
1479
1531
  History
1480
1532
  </button>
@@ -1495,7 +1547,7 @@ export function ApiTesterPage() {
1495
1547
 
1496
1548
  ${displayUrl !== url && html`<div style=${{
1497
1549
  padding: '6px 14px', fontSize: '11px', color: 'var(--text-muted)',
1498
- fontFamily: "'SF Mono', monospace", background: 'rgba(124,58,237,0.04)',
1550
+ fontFamily: "var(--mono)", background: 'rgba(91,140,255,0.04)',
1499
1551
  borderRadius: '6px', marginBottom: '8px', wordBreak: 'break-all',
1500
1552
  }}>${displayUrl}</div>`}
1501
1553
 
@@ -1520,24 +1572,32 @@ export function ApiTesterPage() {
1520
1572
  </button>
1521
1573
  </div>
1522
1574
  <div className="text-sm text-muted" style=${{ marginTop: '6px' }}>
1523
- Reference anywhere with <span style=${{ fontFamily: "'SF Mono', monospace", color: 'var(--accent-light)' }}>{{name}}</span> — URL, headers, body, or Auth.
1575
+ Reference anywhere with <span style=${{ fontFamily: "var(--mono)", color: 'var(--accent-light)' }}>{{name}}</span> — URL, headers, body, or Auth.
1524
1576
  </div>
1525
1577
  </div>`}
1526
1578
 
1527
- ${showHistory && html`<div className="card" style=${{ padding: 0, marginBottom: '12px', overflow: 'hidden' }}>
1528
- <div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
1529
- <span style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>History</span>
1530
- ${history.length > 0 && html`<button className="btn btn-ghost btn-sm" onClick=${clearHistory} style=${{ fontSize: '11px' }}>Clear</button>`}
1579
+ ${showHistory && html`<div className="hist-panel">
1580
+ <div className="hist-head">
1581
+ <span style=${{ fontSize: '12px', fontWeight: 600, marginRight: '4px' }}>History</span>
1582
+ <input placeholder="Filter by method, URL or status…" value=${historyQuery}
1583
+ onInput=${e => setHistoryQuery(e.target.value)} autoFocus />
1584
+ ${history.length > 0 && html`<button className="btn btn-ghost btn-sm" onClick=${clearHistory}>Clear</button>`}
1585
+ </div>
1586
+ <div className="hist-list">
1587
+ ${filteredHistory.length === 0
1588
+ ? html`<div style=${{ padding: '16px', color: 'var(--text-muted)', fontSize: '12px' }}>
1589
+ ${history.length ? 'No matching requests.' : 'No requests yet — sent requests show up here.'}
1590
+ </div>`
1591
+ : filteredHistory.map(h => html`<div key=${h.id} className="hist-row" onClick=${() => loadHistory(h)} title="Load into current tab">
1592
+ <span style=${{ fontWeight: 600, fontFamily: 'var(--mono)', fontSize: '11px', color: methodColors[h.method] || 'var(--text-secondary)', minWidth: '50px' }}>${h.method}</span>
1593
+ <span style=${{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'var(--mono)' }}>${h.url}</span>
1594
+ <span style=${{ fontWeight: 600, fontFamily: 'var(--mono)', color: h.status >= 200 && h.status < 300 ? 'var(--success)' : h.status === 0 ? 'var(--text-muted)' : h.status >= 500 ? 'var(--error)' : 'var(--warning)' }}>${h.status || 'ERR'}</span>
1595
+ <span style=${{ color: 'var(--text-muted)', minWidth: '52px', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>${h.time ? h.time + ' ms' : ''}</span>
1596
+ <span style=${{ color: 'var(--text-muted)', minWidth: '58px', textAlign: 'right' }}>${timeAgo(h.ts)}</span>
1597
+ <button className="btn btn-ghost btn-sm hist-open" title="Open in new tab"
1598
+ onClick=${(e) => { e.stopPropagation(); openHistoryInNewTab(h); }}><${LuPlus} size=${12} /> Tab</button>
1599
+ </div>`)}
1531
1600
  </div>
1532
- ${history.length === 0
1533
- ? html`<div style=${{ padding: '16px', color: 'var(--text-muted)', fontSize: '12px' }}>No requests yet — sent requests show up here.</div>`
1534
- : history.map(h => html`<div key=${h.id}
1535
- onClick=${() => loadHistory(h)}
1536
- style=${{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer', fontSize: '12px' }}>
1537
- <span style=${{ fontWeight: 600, color: methodColors[h.method] || 'var(--text-secondary)', minWidth: '46px' }}>${h.method}</span>
1538
- <span style=${{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: "'SF Mono', monospace" }}>${h.url}</span>
1539
- <span style=${{ fontWeight: 600, color: h.status >= 200 && h.status < 300 ? 'var(--success)' : h.status === 0 ? 'var(--text-muted)' : 'var(--warning)' }}>${h.status || '—'}</span>
1540
- </div>`)}
1541
1601
  </div>`}
1542
1602
 
1543
1603
  <!-- URL Params (:id, etc.) -->
@@ -1547,12 +1607,12 @@ export function ApiTesterPage() {
1547
1607
  </div>
1548
1608
  ${urlParams.map(p => html`<div key=${p.id} className="api-tester-kv-row">
1549
1609
  <input value=${p.key} disabled style=${{
1550
- flex: '0 0 120px', opacity: 0.6, fontWeight: 600, fontFamily: "'SF Mono', monospace",
1610
+ flex: '0 0 120px', opacity: 0.6, fontWeight: 600, fontFamily: "var(--mono)",
1551
1611
  color: 'var(--accent-light)', background: 'var(--bg-input)', fontSize: '12px',
1552
1612
  }} />
1553
1613
  <input placeholder=${'Enter ' + p.key + ' value'} value=${p.value}
1554
1614
  onInput=${(e) => setUrlParams(prev => prev.map(up => up.id === p.id ? { ...up, value: e.target.value } : up))}
1555
- style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px' }} />
1615
+ style=${{ fontFamily: "var(--mono)", fontSize: '12px' }} />
1556
1616
  </div>`)}
1557
1617
  </div>`}
1558
1618
 
@@ -1837,8 +1897,8 @@ export function ApiTesterPage() {
1837
1897
  display: 'flex', padding: '4px 14px', fontSize: '12px', gap: '12px',
1838
1898
  borderBottom: '1px solid rgba(255,255,255,0.03)',
1839
1899
  }}>
1840
- <span style=${{ fontWeight: 600, color: 'var(--accent-light)', minWidth: '180px', fontFamily: "'SF Mono', monospace", fontSize: '11px' }}>${k}</span>
1841
- <span style=${{ color: 'var(--text-secondary)', fontFamily: "'SF Mono', monospace", fontSize: '11px', wordBreak: 'break-all' }}>${v}</span>
1900
+ <span style=${{ fontWeight: 600, color: 'var(--accent-light)', minWidth: '180px', fontFamily: "var(--mono)", fontSize: '11px' }}>${k}</span>
1901
+ <span style=${{ color: 'var(--text-secondary)', fontFamily: "var(--mono)", fontSize: '11px', wordBreak: 'break-all' }}>${v}</span>
1842
1902
  </div>`)}
1843
1903
  </div>`}
1844
1904
  </div>`}
@@ -1911,7 +1971,7 @@ export function ApiTesterPage() {
1911
1971
  onMouseOver=${(e) => e.currentTarget.style.background = 'var(--bg-input)'}
1912
1972
  onMouseOut=${(e) => e.currentTarget.style.background = 'transparent'}>
1913
1973
  <span className=${`route-method ${ep.method.toLowerCase()}`} style=${{ fontSize: '10px', padding: '2px 7px' }}>${ep.method}</span>
1914
- <span style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px', color: 'var(--text-primary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${ep.fullPath}</span>
1974
+ <span style=${{ fontFamily: "var(--mono)", fontSize: '12px', color: 'var(--text-primary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${ep.fullPath}</span>
1915
1975
  ${ep.bodyFields?.length > 0 && html`<span style=${{ fontSize: '10px', color: 'var(--text-muted)', flexShrink: 0 }}>${ep.bodyFields.length} fields</span>`}
1916
1976
  </div>`)}
1917
1977
  </div>`)}