Initial release — APIClient - Agent v2.0.0
AI-first API testing desktop client built with Python + PyQt6. Features: - Multi-tab HTTP request editor with params/headers/body/auth/tests - KeyValueTable with per-row enable/disable checkboxes and 36px rows - Format JSON button, syntax highlighting, pre-request & test scripts - Collections, environments, history, import/export (Postman v2.1, cURL) - OpenAPI 3.x / Swagger 2.0 local parser (no AI tokens) - EKIKA Odoo API Framework generator — JSON-API, REST JSON, GraphQL, Custom REST JSON with all auth types (instant, no AI tokens) - Persistent AI chat sidebar (Claude-powered co-pilot) with streaming, context-aware suggestions, and one-click Apply to request editor - AI collection generator from any docs URL or pasted spec - WebSocket client, Mock server, Collection runner, Code generator - Dark/light theme engine (global QSS, object-name selectors) - SSL error detection with actionable hints - MIT License Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
290
app/ui/response_panel.py
Normal file
290
app/ui/response_panel.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""APIClient - Agent — Response Panel."""
|
||||
import json
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTabWidget,
|
||||
QTextEdit, QTableWidget, QTableWidgetItem, QHeaderView,
|
||||
QPushButton, QLineEdit, QApplication, QTreeWidget,
|
||||
QTreeWidgetItem, QFrame, QFileDialog, QStackedWidget
|
||||
)
|
||||
from PyQt6.QtGui import QFont, QTextDocument, QTextCursor, QBrush, QColor
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from app.ui.theme import Colors, status_color
|
||||
from app.ui.highlighter import JsonHighlighter
|
||||
from app.models import HttpResponse, TestResult
|
||||
|
||||
|
||||
def _fmt_size(n: int) -> str:
|
||||
if n < 1024:
|
||||
return f"{n} B"
|
||||
if n < 1024 * 1024:
|
||||
return f"{n / 1024:.1f} KB"
|
||||
return f"{n / (1024 * 1024):.2f} MB"
|
||||
|
||||
|
||||
class StatusBadge(QLabel):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__("—", parent)
|
||||
self.setFixedHeight(26)
|
||||
self._apply_style(Colors.TEXT_MUTED)
|
||||
self.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold))
|
||||
|
||||
def _apply_style(self, color: str):
|
||||
# Inline style intentional — badge color is dynamic per status code
|
||||
self.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
color: {color};
|
||||
background: {color}18;
|
||||
border: 1px solid {color}50;
|
||||
border-radius: 4px;
|
||||
padding: 2px 10px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
""")
|
||||
|
||||
def set_status(self, code: int, reason: str):
|
||||
color = status_color(code)
|
||||
self.setText(f" {code} {reason} ")
|
||||
self._apply_style(color)
|
||||
|
||||
def set_error(self):
|
||||
self.setText(" ERROR ")
|
||||
self._apply_style(Colors.ERROR)
|
||||
|
||||
def clear(self):
|
||||
self.setText("—")
|
||||
self._apply_style(Colors.TEXT_MUTED)
|
||||
|
||||
|
||||
class ResponsePanel(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# ── Top bar ──────────────────────────────────────────────────────────
|
||||
top_bar = QWidget()
|
||||
top_bar.setObjectName("responseBar")
|
||||
top_bar.setFixedHeight(42)
|
||||
top_layout = QHBoxLayout(top_bar)
|
||||
top_layout.setContentsMargins(12, 0, 12, 0)
|
||||
top_layout.setSpacing(8)
|
||||
|
||||
resp_label = QLabel("RESPONSE")
|
||||
resp_label.setObjectName("responseTitle")
|
||||
top_layout.addWidget(resp_label)
|
||||
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.Shape.VLine)
|
||||
top_layout.addWidget(sep)
|
||||
|
||||
self.status_badge = StatusBadge()
|
||||
self.time_label = QLabel("")
|
||||
self.time_label.setObjectName("metaLabel")
|
||||
self.size_label = QLabel("")
|
||||
self.size_label.setObjectName("metaLabel")
|
||||
top_layout.addWidget(self.status_badge)
|
||||
top_layout.addWidget(self.time_label)
|
||||
top_layout.addWidget(self.size_label)
|
||||
top_layout.addStretch()
|
||||
|
||||
# Search bar
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setObjectName("searchBar")
|
||||
self.search_input.setPlaceholderText("Search response…")
|
||||
self.search_input.setFixedWidth(200)
|
||||
self.search_input.textChanged.connect(self._on_search)
|
||||
|
||||
prev_btn = QPushButton("↑")
|
||||
prev_btn.setObjectName("ghost")
|
||||
prev_btn.setFixedSize(26, 26)
|
||||
prev_btn.setToolTip("Previous match")
|
||||
prev_btn.clicked.connect(lambda: self._nav(backward=True))
|
||||
|
||||
next_btn = QPushButton("↓")
|
||||
next_btn.setObjectName("ghost")
|
||||
next_btn.setFixedSize(26, 26)
|
||||
next_btn.setToolTip("Next match")
|
||||
next_btn.clicked.connect(lambda: self._nav(backward=False))
|
||||
|
||||
self.match_label = QLabel("")
|
||||
self.match_label.setObjectName("metaLabel")
|
||||
|
||||
self.copy_btn = QPushButton("Copy")
|
||||
self.copy_btn.setObjectName("ghost")
|
||||
self.copy_btn.setFixedWidth(55)
|
||||
self.copy_btn.setToolTip("Copy response body")
|
||||
self.copy_btn.clicked.connect(self._copy)
|
||||
|
||||
self.save_btn = QPushButton("Save")
|
||||
self.save_btn.setObjectName("ghost")
|
||||
self.save_btn.setFixedWidth(55)
|
||||
self.save_btn.setToolTip("Save response body to file")
|
||||
self.save_btn.clicked.connect(self._save)
|
||||
|
||||
top_layout.addWidget(self.search_input)
|
||||
top_layout.addWidget(prev_btn)
|
||||
top_layout.addWidget(next_btn)
|
||||
top_layout.addWidget(self.match_label)
|
||||
top_layout.addWidget(self.copy_btn)
|
||||
top_layout.addWidget(self.save_btn)
|
||||
layout.addWidget(top_bar)
|
||||
|
||||
# ── Stacked: content tabs + loading overlay ───────────────────────
|
||||
self._stack = QStackedWidget()
|
||||
|
||||
# ── Content tabs ────────────────────────────────────────────────────
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setObjectName("innerTabs")
|
||||
|
||||
# Body view
|
||||
self.body_view = QTextEdit()
|
||||
self.body_view.setObjectName("codeEditor")
|
||||
self.body_view.setReadOnly(True)
|
||||
self.body_view.setFont(
|
||||
QFont("JetBrains Mono, Fira Code, Cascadia Code, Consolas, monospace", 11)
|
||||
)
|
||||
self._hl = JsonHighlighter(self.body_view.document())
|
||||
self.tabs.addTab(self.body_view, "Body")
|
||||
|
||||
# Headers table
|
||||
self.headers_table = QTableWidget(0, 2)
|
||||
self.headers_table.setHorizontalHeaderLabels(["Header", "Value"])
|
||||
self.headers_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
self.headers_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.headers_table.verticalHeader().setVisible(False)
|
||||
self.tabs.addTab(self.headers_table, "Headers")
|
||||
|
||||
# Test results tree
|
||||
self.test_tree = QTreeWidget()
|
||||
self.test_tree.setHeaderLabels(["Test", "Result"])
|
||||
self.test_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
self.test_tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.tabs.addTab(self.test_tree, "Tests")
|
||||
|
||||
# ── Loading overlay ──────────────────────────────────────────────────
|
||||
loading_widget = QWidget()
|
||||
loading_widget.setObjectName("loadingOverlay")
|
||||
ll = QVBoxLayout(loading_widget)
|
||||
ll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._loading_label = QLabel("Sending request…")
|
||||
self._loading_label.setObjectName("loadingLabel")
|
||||
self._loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
ll.addWidget(self._loading_label)
|
||||
|
||||
self._stack.addWidget(self.tabs) # index 0 — normal view
|
||||
self._stack.addWidget(loading_widget) # index 1 — loading
|
||||
|
||||
layout.addWidget(self._stack, 1)
|
||||
|
||||
self._match_positions: list = []
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def set_loading(self, loading: bool):
|
||||
self._stack.setCurrentIndex(1 if loading else 0)
|
||||
|
||||
def display(self, resp: HttpResponse, test_results: list = None):
|
||||
self.set_loading(False)
|
||||
|
||||
if resp.error:
|
||||
self.status_badge.set_error()
|
||||
self.time_label.setText("")
|
||||
self.size_label.setText("")
|
||||
self.body_view.setPlainText(resp.error)
|
||||
self.tabs.setCurrentIndex(0)
|
||||
return
|
||||
|
||||
# Status / timing / size
|
||||
self.status_badge.set_status(resp.status, resp.reason)
|
||||
self.time_label.setText(f"{resp.elapsed_ms:.0f} ms")
|
||||
size = resp.size_bytes or len(resp.body.encode())
|
||||
self.size_label.setText(_fmt_size(size))
|
||||
|
||||
# Body — pretty-print JSON if possible
|
||||
try:
|
||||
parsed = json.loads(resp.body)
|
||||
self.body_view.setPlainText(json.dumps(parsed, indent=2, ensure_ascii=False))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
self.body_view.setPlainText(resp.body)
|
||||
|
||||
# Response headers
|
||||
self.headers_table.setRowCount(0)
|
||||
for k, v in sorted(resp.headers.items()):
|
||||
row = self.headers_table.rowCount()
|
||||
self.headers_table.insertRow(row)
|
||||
ki = QTableWidgetItem(k)
|
||||
ki.setForeground(QBrush(QColor(Colors.INFO)))
|
||||
self.headers_table.setItem(row, 0, ki)
|
||||
self.headers_table.setItem(row, 1, QTableWidgetItem(v))
|
||||
|
||||
# Test results
|
||||
self.test_tree.clear()
|
||||
if test_results:
|
||||
passed = sum(1 for t in test_results if t.passed)
|
||||
total = len(test_results)
|
||||
color = Colors.SUCCESS if passed == total else Colors.WARNING
|
||||
summary = QTreeWidgetItem([f"Results: {passed}/{total} passed", ""])
|
||||
summary.setForeground(0, QBrush(QColor(color)))
|
||||
self.test_tree.addTopLevelItem(summary)
|
||||
for tr in test_results:
|
||||
icon = "✓" if tr.passed else "✗"
|
||||
child = QTreeWidgetItem([f" {icon} {tr.name}", tr.message])
|
||||
child.setForeground(0, QBrush(QColor(Colors.SUCCESS if tr.passed else Colors.ERROR)))
|
||||
summary.addChild(child)
|
||||
summary.setExpanded(True)
|
||||
self.tabs.setTabText(2, f"Tests ({passed}/{total})")
|
||||
self.tabs.setCurrentIndex(2 if test_results else 0)
|
||||
else:
|
||||
self.tabs.setTabText(2, "Tests")
|
||||
self.tabs.setCurrentIndex(0)
|
||||
|
||||
def clear(self):
|
||||
self.status_badge.clear()
|
||||
self.time_label.setText("")
|
||||
self.size_label.setText("")
|
||||
self.body_view.clear()
|
||||
self.headers_table.setRowCount(0)
|
||||
self.test_tree.clear()
|
||||
self.tabs.setTabText(2, "Tests")
|
||||
self.match_label.setText("")
|
||||
|
||||
# ── Search ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_search(self, text: str):
|
||||
if not text:
|
||||
self.match_label.setText("")
|
||||
return
|
||||
self._nav(backward=False)
|
||||
|
||||
def _nav(self, backward: bool):
|
||||
text = self.search_input.text()
|
||||
if not text:
|
||||
return
|
||||
flag = QTextDocument.FindFlag.FindBackward if backward else QTextDocument.FindFlag(0)
|
||||
found = self.body_view.find(text, flag)
|
||||
if not found:
|
||||
cursor = self.body_view.textCursor()
|
||||
cursor.movePosition(
|
||||
QTextCursor.MoveOperation.End if backward else QTextCursor.MoveOperation.Start
|
||||
)
|
||||
self.body_view.setTextCursor(cursor)
|
||||
self.body_view.find(text, flag)
|
||||
|
||||
def _copy(self):
|
||||
text = self.body_view.toPlainText()
|
||||
if text:
|
||||
QApplication.clipboard().setText(text)
|
||||
|
||||
def _save(self):
|
||||
text = self.body_view.toPlainText()
|
||||
if not text:
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Save Response", "response.json", "JSON (*.json);;Text (*.txt);;All Files (*)"
|
||||
)
|
||||
if path:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
Reference in New Issue
Block a user