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:
2026-03-28 17:34:18 +05:30
parent 1dbbb4320b
commit 01662f7e0e
37 changed files with 7822 additions and 1 deletions

268
app/ui/sidebar.py Normal file
View File

@@ -0,0 +1,268 @@
"""APIClient - Agent — Collections Sidebar."""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem,
QPushButton, QInputDialog, QMenu, QLineEdit, QLabel, QMessageBox
)
from PyQt6.QtCore import pyqtSignal, Qt
from PyQt6.QtGui import QColor, QFont, QBrush
from app.ui.theme import Colors, method_color
from app.core import storage
from app.models import HttpRequest
class CollectionsSidebar(QWidget):
request_selected = pyqtSignal(object)
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("sidebar")
self.setMinimumWidth(240)
self.setMaximumWidth(380)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── Header ────────────────────────────────────────────────────────────
header = QWidget()
header.setObjectName("sidebarHeader")
header.setFixedHeight(44)
h_layout = QHBoxLayout(header)
h_layout.setContentsMargins(12, 0, 8, 0)
title = QLabel("COLLECTIONS")
title.setObjectName("sectionLabel")
self.add_col_btn = QPushButton("+")
self.add_col_btn.setObjectName("ghost")
self.add_col_btn.setFixedSize(28, 28)
self.add_col_btn.setToolTip("New Collection (Ctrl+Shift+N)")
self.add_col_btn.clicked.connect(self._add_collection)
h_layout.addWidget(title)
h_layout.addStretch()
h_layout.addWidget(self.add_col_btn)
layout.addWidget(header)
# ── Filter ────────────────────────────────────────────────────────────
search_wrap = QWidget()
search_wrap.setObjectName("sidebarSearch")
sw = QHBoxLayout(search_wrap)
sw.setContentsMargins(10, 6, 10, 6)
self.search_input = QLineEdit()
self.search_input.setObjectName("filterInput")
self.search_input.setPlaceholderText("Filter collections…")
self.search_input.textChanged.connect(self._filter)
sw.addWidget(self.search_input)
layout.addWidget(search_wrap)
# ── Tree ─────────────────────────────────────────────────────────────
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
self.tree.setIndentation(16)
self.tree.setAnimated(True)
self.tree.itemDoubleClicked.connect(self._on_double_click)
self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.tree.customContextMenuRequested.connect(self._context_menu)
layout.addWidget(self.tree)
# History root (always last in tree)
self._history_root = QTreeWidgetItem([" History"])
self._history_root.setForeground(0, QBrush(QColor(Colors.TEXT_MUTED)))
self._history_root.setFont(0, self._section_font())
self.tree.addTopLevelItem(self._history_root)
self._load_collections()
self._load_history()
# ── Helpers ───────────────────────────────────────────────────────────────
def _section_font(self) -> QFont:
f = QFont()
f.setPointSize(9)
f.setWeight(QFont.Weight.Bold)
return f
def _make_req_item(self, req: dict) -> QTreeWidgetItem:
method = req.get("method", "GET")
name = req.get("name") or req.get("url", "Untitled")
item = QTreeWidgetItem()
item.setText(0, f" {method} {name}")
item.setForeground(0, QBrush(QColor(method_color(method))))
item.setData(0, Qt.ItemDataRole.UserRole, {"type": "request", "req": req})
return item
def _make_collection_item(self, col: dict) -> QTreeWidgetItem:
item = QTreeWidgetItem([f" {col['name']}"])
item.setForeground(0, QBrush(QColor(Colors.TEXT_PRIMARY)))
item.setFont(0, self._section_font())
item.setData(0, Qt.ItemDataRole.UserRole, {"type": "collection", "id": col["id"]})
return item
def _make_folder_item(self, folder: dict) -> QTreeWidgetItem:
item = QTreeWidgetItem([f"{folder['name']}"])
item.setForeground(0, QBrush(QColor(Colors.TEXT_SECONDARY)))
item.setData(
0, Qt.ItemDataRole.UserRole,
{"type": "folder", "id": folder["id"], "col_id": folder["collection_id"]}
)
return item
def _dict_to_request(self, r: dict) -> HttpRequest:
return HttpRequest(
method = r.get("method") or "GET",
url = r.get("url") or "",
headers = r.get("headers") or {},
params = r.get("params") or {},
body = r.get("body") or "",
body_type = r.get("body_type") or "raw",
content_type = r.get("content_type") or "",
auth_type = r.get("auth_type") or "none",
auth_data = r.get("auth_data") or {},
pre_request_script = r.get("pre_request_script") or "",
test_script = r.get("test_script") or "",
name = r.get("name") or "",
id = r.get("id"),
timeout = r.get("timeout") or 30,
ssl_verify = bool(r.get("ssl_verify", 1)),
)
# ── Data loading ──────────────────────────────────────────────────────────
def _load_collections(self, filter_text: str = ""):
for i in range(self.tree.topLevelItemCount() - 1, -1, -1):
if self.tree.topLevelItem(i) is not self._history_root:
self.tree.takeTopLevelItem(i)
for col in storage.get_collections():
if filter_text and filter_text.lower() not in col["name"].lower():
continue
col_item = self._make_collection_item(col)
for folder in storage.get_folders(col["id"]):
folder_item = self._make_folder_item(folder)
for req in storage.get_requests(col["id"], folder["id"]):
folder_item.addChild(self._make_req_item(req))
col_item.addChild(folder_item)
for req in storage.get_requests(col["id"]):
col_item.addChild(self._make_req_item(req))
self.tree.insertTopLevelItem(0, col_item)
col_item.setExpanded(True)
def _load_history(self):
self._history_root.takeChildren()
for h in storage.get_history(30):
item = QTreeWidgetItem()
method = h.get("method", "GET")
url = h.get("url", "")
item.setText(0, f" {method} {url}")
item.setForeground(0, QBrush(QColor(method_color(method))))
item.setData(0, Qt.ItemDataRole.UserRole, {"type": "history", "req": h})
self._history_root.addChild(item)
def _filter(self, text: str):
self._load_collections(filter_text=text)
def refresh(self):
self._load_collections(self.search_input.text())
self._load_history()
# ── Interaction ───────────────────────────────────────────────────────────
def _on_double_click(self, item, _column):
data = item.data(0, Qt.ItemDataRole.UserRole)
if not data or data["type"] not in ("request", "history"):
return
self.request_selected.emit(self._dict_to_request(data["req"]))
def _context_menu(self, pos):
item = self.tree.itemAt(pos)
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not data:
return
menu = QMenu(self)
t = data["type"]
if t == "collection":
menu.addAction("Add Folder", lambda: self._add_folder(data["id"]))
menu.addAction("Rename", lambda: self._rename_collection(item, data["id"]))
menu.addSeparator()
menu.addAction("Delete", lambda: self._delete_collection(item, data["id"]))
elif t == "folder":
menu.addAction("Rename", lambda: self._rename_folder(item, data["id"]))
menu.addSeparator()
menu.addAction("Delete", lambda: self._delete_folder(data["id"]))
elif t == "request":
menu.addAction("Open in Tab", lambda: self.request_selected.emit(
self._dict_to_request(data["req"])
))
menu.addSeparator()
menu.addAction("Delete", lambda: self._delete_request(data["req"].get("id")))
elif t == "history":
menu.addAction("Open in Tab", lambda: self.request_selected.emit(
self._dict_to_request(data["req"])
))
menu.addSeparator()
menu.addAction("Clear All History", self._clear_history)
menu.exec(self.tree.mapToGlobal(pos))
# ── CRUD actions ──────────────────────────────────────────────────────────
def _add_collection(self):
name, ok = QInputDialog.getText(self, "New Collection", "Collection name:")
if ok and name.strip():
storage.add_collection(name.strip())
self._load_collections()
def _add_folder(self, col_id: int):
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip():
storage.add_folder(col_id, name.strip())
self._load_collections()
def _rename_collection(self, item, col_id: int):
current = item.text(0).strip()
name, ok = QInputDialog.getText(self, "Rename Collection", "Name:", text=current)
if ok and name.strip():
storage.rename_collection(col_id, name.strip())
self._load_collections()
def _rename_folder(self, item, folder_id: int):
current = item.text(0).strip().lstrip("").strip()
name, ok = QInputDialog.getText(self, "Rename Folder", "Name:", text=current)
if ok and name.strip():
storage.rename_folder(folder_id, name.strip())
self._load_collections()
def _delete_collection(self, item, col_id: int):
name = item.text(0).strip()
reply = QMessageBox.question(
self, "Delete Collection",
f"Delete '{name}' and all its requests? This cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel
)
if reply == QMessageBox.StandardButton.Yes:
storage.delete_collection(col_id)
self._load_collections()
def _delete_folder(self, folder_id: int):
reply = QMessageBox.question(
self, "Delete Folder",
"Delete this folder and all its requests?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel
)
if reply == QMessageBox.StandardButton.Yes:
storage.delete_folder(folder_id)
self._load_collections()
def _delete_request(self, req_id):
if req_id:
storage.delete_request(req_id)
self._load_collections()
def _clear_history(self):
storage.clear_history()
self._load_history()