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:
268
app/ui/sidebar.py
Normal file
268
app/ui/sidebar.py
Normal 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()
|
||||
Reference in New Issue
Block a user