"""APIClient - Agent - Request Panel.""" from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QComboBox, QLineEdit, QPushButton, QTabWidget, QTableWidget, QTableWidgetItem, QTextEdit, QHeaderView, QLabel, QFormLayout, QStackedWidget, QCheckBox, QSpinBox ) from PyQt6.QtCore import pyqtSignal, Qt from PyQt6.QtGui import QFont from app.ui.theme import Colors, method_color from app.ui.highlighter import JsonHighlighter from app.models import HttpRequest HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] class KeyValueTable(QTableWidget): """Editable key-value table with enable/disable checkboxes per row.""" def __init__(self, key_hint: str = "Key", val_hint: str = "Value", parent=None): super().__init__(0, 3, parent) self.setHorizontalHeaderLabels(["", key_hint, val_hint]) hh = self.horizontalHeader() hh.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) hh.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) hh.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) self.setColumnWidth(0, 32) self.verticalHeader().setVisible(False) self.verticalHeader().setDefaultSectionSize(36) self.setAlternatingRowColors(True) self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self._add_empty_row() self.itemChanged.connect(self._on_item_changed) def _make_checkbox_item(self, checked: bool = True) -> QTableWidgetItem: item = QTableWidgetItem() item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled) item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked) return item def _add_empty_row(self): row = self.rowCount() self.blockSignals(True) self.insertRow(row) self.setItem(row, 0, self._make_checkbox_item(True)) self.setItem(row, 1, QTableWidgetItem("")) self.setItem(row, 2, QTableWidgetItem("")) self.blockSignals(False) def _on_item_changed(self, item): if item.column() in (1, 2) and item.row() == self.rowCount() - 1 and item.text().strip(): self._add_empty_row() def get_pairs(self) -> dict: result = {} for row in range(self.rowCount()): chk = self.item(row, 0) if chk and chk.checkState() == Qt.CheckState.Unchecked: continue k = self.item(row, 1) v = self.item(row, 2) key = k.text().strip() if k else "" val = v.text().strip() if v else "" if key: result[key] = val return result def set_pairs(self, pairs: dict): self.blockSignals(True) self.setRowCount(0) for k, v in pairs.items(): row = self.rowCount() self.insertRow(row) self.setItem(row, 0, self._make_checkbox_item(True)) self.setItem(row, 1, QTableWidgetItem(str(k))) self.setItem(row, 2, QTableWidgetItem(str(v))) self.blockSignals(False) self._add_empty_row() class AuthWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) layout = QVBoxLayout(self) layout.setContentsMargins(16, 12, 16, 12) layout.setSpacing(10) row = QHBoxLayout() row.addWidget(QLabel("Auth Type:")) self.type_combo = QComboBox() self.type_combo.addItems(["None", "Bearer Token", "Basic Auth", "API Key"]) self.type_combo.setMaximumWidth(200) self.type_combo.currentIndexChanged.connect(self._on_type) row.addWidget(self.type_combo) row.addStretch() layout.addLayout(row) self.stack = QStackedWidget() # None page none_lbl = QLabel("No authentication configured.") none_lbl.setObjectName("authNone") self.stack.addWidget(none_lbl) # Bearer page bearer_w = QWidget() fl = QFormLayout(bearer_w) fl.setContentsMargins(0, 8, 0, 0) self.bearer_token = QLineEdit() self.bearer_token.setPlaceholderText("{{token}}") self.bearer_token.setEchoMode(QLineEdit.EchoMode.Password) show_bearer = QCheckBox("Show") show_bearer.toggled.connect( lambda on: self.bearer_token.setEchoMode( QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password ) ) tok_row = QHBoxLayout() tok_row.addWidget(self.bearer_token) tok_row.addWidget(show_bearer) fl.addRow("Token:", tok_row) self.stack.addWidget(bearer_w) # Basic page basic_w = QWidget() fl2 = QFormLayout(basic_w) fl2.setContentsMargins(0, 8, 0, 0) self.basic_user = QLineEdit() self.basic_user.setPlaceholderText("username") self.basic_pass = QLineEdit() self.basic_pass.setPlaceholderText("password") self.basic_pass.setEchoMode(QLineEdit.EchoMode.Password) show_pass = QCheckBox("Show") show_pass.toggled.connect( lambda on: self.basic_pass.setEchoMode( QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password ) ) pass_row = QHBoxLayout() pass_row.addWidget(self.basic_pass) pass_row.addWidget(show_pass) fl2.addRow("Username:", self.basic_user) fl2.addRow("Password:", pass_row) self.stack.addWidget(basic_w) # API Key page apikey_w = QWidget() fl3 = QFormLayout(apikey_w) fl3.setContentsMargins(0, 8, 0, 0) self.apikey_key = QLineEdit() self.apikey_key.setPlaceholderText("X-API-Key") self.apikey_value = QLineEdit() self.apikey_value.setPlaceholderText("{{api_key}}") self.apikey_in = QComboBox() self.apikey_in.addItems(["header", "query"]) fl3.addRow("Key:", self.apikey_key) fl3.addRow("Value:", self.apikey_value) fl3.addRow("Add to:", self.apikey_in) self.stack.addWidget(apikey_w) layout.addWidget(self.stack) layout.addStretch() def _on_type(self, idx: int): self.stack.setCurrentIndex(idx) def get_auth(self) -> tuple[str, dict]: idx = self.type_combo.currentIndex() if idx == 1: return "bearer", {"token": self.bearer_token.text()} if idx == 2: return "basic", {"username": self.basic_user.text(), "password": self.basic_pass.text()} if idx == 3: return "apikey", { "key": self.apikey_key.text(), "value": self.apikey_value.text(), "in": self.apikey_in.currentText(), } return "none", {} def set_auth(self, auth_type: str, auth_data: dict): idx_map = {"none": 0, "bearer": 1, "basic": 2, "apikey": 3} self.type_combo.setCurrentIndex(idx_map.get(auth_type, 0)) if auth_type == "bearer": self.bearer_token.setText(auth_data.get("token", "")) elif auth_type == "basic": self.basic_user.setText(auth_data.get("username", "")) self.basic_pass.setText(auth_data.get("password", "")) elif auth_type == "apikey": self.apikey_key.setText(auth_data.get("key", "")) self.apikey_value.setText(auth_data.get("value", "")) self.apikey_in.setCurrentText(auth_data.get("in", "header")) def _mono_editor(placeholder: str = "") -> QTextEdit: e = QTextEdit() e.setObjectName("codeEditor") e.setPlaceholderText(placeholder) e.setAcceptRichText(False) e.setFont(QFont("JetBrains Mono, Fira Code, Cascadia Code, Consolas, monospace", 11)) return e class RequestPanel(QWidget): send_requested = pyqtSignal(object) def __init__(self, parent=None): super().__init__(parent) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # ── URL bar ────────────────────────────────────────────────────────── url_bar = QWidget() url_bar.setObjectName("urlBarStrip") url_bar.setFixedHeight(56) url_layout = QHBoxLayout(url_bar) url_layout.setContentsMargins(12, 0, 12, 0) url_layout.setSpacing(8) self.method_combo = QComboBox() self.method_combo.setObjectName("methodCombo") self.method_combo.addItems(HTTP_METHODS) self.method_combo.setFixedWidth(105) self.method_combo.currentTextChanged.connect(self._on_method_changed) self.url_input = QLineEdit() self.url_input.setObjectName("urlBar") self.url_input.setPlaceholderText("Enter URL - e.g. https://api.example.com/v1/users") self.url_input.returnPressed.connect(self._send) self.send_btn = QPushButton("Send") self.send_btn.setObjectName("sendBtn") self.send_btn.setFixedWidth(90) self.send_btn.setToolTip("Send request (Ctrl+Enter)") self.send_btn.clicked.connect(self._send) url_layout.addWidget(self.method_combo) url_layout.addWidget(self.url_input, 1) url_layout.addWidget(self.send_btn) layout.addWidget(url_bar) # ── Request tabs ───────────────────────────────────────────────────── self.tabs = QTabWidget() self.tabs.setObjectName("innerTabs") # Params / Headers self.params_table = KeyValueTable("Parameter", "Value") self.headers_table = KeyValueTable("Header", "Value") # Auth self.auth_widget = AuthWidget() # Body tab body_w = QWidget() bl = QVBoxLayout(body_w) bl.setContentsMargins(12, 8, 12, 8) bl.setSpacing(6) type_row = QHBoxLayout() type_row.addWidget(QLabel("Format:")) self.body_type_combo = QComboBox() self.body_type_combo.addItems(["raw", "form-urlencoded", "form-data"]) self.body_type_combo.setMaximumWidth(160) self.body_type_combo.currentTextChanged.connect(self._on_body_type_changed) type_row.addWidget(self.body_type_combo) type_row.addSpacing(12) self.ct_label = QLabel("Content-Type:") self.ct_label.setObjectName("fieldLabel") self.ct_combo = QComboBox() self.ct_combo.addItems([ "application/vnd.api+json", "application/json", "application/xml", "text/plain", "text/html", "application/x-www-form-urlencoded", ]) self.ct_combo.setMaximumWidth(230) type_row.addWidget(self.ct_label) type_row.addWidget(self.ct_combo) type_row.addStretch() fmt_btn = QPushButton("{ } Format") fmt_btn.setObjectName("ghost") fmt_btn.setFixedHeight(28) fmt_btn.setToolTip("Pretty-print / format JSON body (Ctrl+Shift+F)") fmt_btn.clicked.connect(self._format_body) type_row.addWidget(fmt_btn) bl.addLayout(type_row) self.body_editor = _mono_editor('{\n "key": "value"\n}') self._body_hl = JsonHighlighter(self.body_editor.document()) bl.addWidget(self.body_editor) # Pre-request scripts pre_w = QWidget() pl = QVBoxLayout(pre_w) pl.setContentsMargins(12, 8, 12, 8) hint = QLabel("Python executed before the request. Use pm.environment.get('key') to read variables.") hint.setObjectName("hintText") hint.setWordWrap(True) pl.addWidget(hint) self.pre_script_editor = _mono_editor("# Example:\n# pm.environment.set('token', 'my-value')") pl.addWidget(self.pre_script_editor) # Test scripts test_w = QWidget() tl = QVBoxLayout(test_w) tl.setContentsMargins(12, 8, 12, 8) hint2 = QLabel("Assertions run automatically after each response is received.") hint2.setObjectName("hintText") hint2.setWordWrap(True) tl.addWidget(hint2) self.test_editor = _mono_editor( "pm.test('Status is 200', lambda: pm.response.to_have_status(200))\n" "pm.test('Has body', lambda: expect(pm.response.text).to_be_truthy())" ) tl.addWidget(self.test_editor) # Settings tab (timeout, SSL) settings_filler = QWidget() sl_outer = QVBoxLayout(settings_filler) sl_outer.setContentsMargins(0, 0, 0, 0) sl = QFormLayout() sl.setContentsMargins(16, 12, 16, 12) sl.setSpacing(10) self.timeout_spin = QSpinBox() self.timeout_spin.setRange(1, 300) self.timeout_spin.setValue(30) self.timeout_spin.setSuffix(" s") self.timeout_spin.setToolTip("Request timeout in seconds") sl.addRow("Timeout:", self.timeout_spin) self.ssl_check = QCheckBox("Verify SSL certificate") self.ssl_check.setChecked(True) self.ssl_check.setToolTip("Uncheck to allow self-signed or invalid certificates") sl.addRow("SSL:", self.ssl_check) sl_outer.addLayout(sl) sl_outer.addStretch() self.tabs.addTab(self.params_table, "Params") self.tabs.addTab(self.headers_table, "Headers") self.tabs.addTab(self.auth_widget, "Auth") self.tabs.addTab(body_w, "Body") self.tabs.addTab(pre_w, "Pre-request") self.tabs.addTab(test_w, "Tests") self.tabs.addTab(settings_filler, "Settings") layout.addWidget(self.tabs, 1) # Apply initial method color after all widgets are built self._on_method_changed(self.method_combo.currentText()) # ── Slots ──────────────────────────────────────────────────────────────── def _on_method_changed(self, method: str): # Inline style is intentional here - color is dynamic per method value color = method_color(method) self.method_combo.setStyleSheet(f"QComboBox#methodCombo {{ color: {color}; }}") def _on_body_type_changed(self, body_type: str): raw = body_type == "raw" self.ct_label.setVisible(raw) self.ct_combo.setVisible(raw) def _format_body(self): import json text = self.body_editor.toPlainText().strip() if not text: return try: parsed = json.loads(text) self.body_editor.setPlainText(json.dumps(parsed, indent=2, ensure_ascii=False)) except json.JSONDecodeError: pass # not valid JSON - leave as-is def _send(self): self.send_requested.emit(self._build_request()) # ── Public API ──────────────────────────────────────────────────────────── def get_request(self) -> HttpRequest: return self._build_request() def _build_request(self) -> HttpRequest: auth_type, auth_data = self.auth_widget.get_auth() body_type = self.body_type_combo.currentText() content_type = self.ct_combo.currentText() if body_type == "raw" else "" return HttpRequest( method = self.method_combo.currentText(), url = self.url_input.text().strip(), headers = self.headers_table.get_pairs(), params = self.params_table.get_pairs(), body = self.body_editor.toPlainText(), body_type = body_type, content_type = content_type, auth_type = auth_type, auth_data = auth_data, pre_request_script = self.pre_script_editor.toPlainText(), test_script = self.test_editor.toPlainText(), timeout = self.timeout_spin.value(), ssl_verify = self.ssl_check.isChecked(), ) def load_request(self, req: HttpRequest): idx = self.method_combo.findText(req.method) if idx >= 0: self.method_combo.setCurrentIndex(idx) self.url_input.setText(req.url) self.headers_table.set_pairs(req.headers or {}) self.params_table.set_pairs(req.params or {}) self.body_editor.setPlainText(req.body or "") self.body_type_combo.setCurrentText(req.body_type or "raw") if req.content_type: idx_ct = self.ct_combo.findText(req.content_type) if idx_ct >= 0: self.ct_combo.setCurrentIndex(idx_ct) self.auth_widget.set_auth(req.auth_type or "none", req.auth_data or {}) self.pre_script_editor.setPlainText(req.pre_request_script or "") self.test_editor.setPlainText(req.test_script or "") self.timeout_spin.setValue(req.timeout or 30) self.ssl_check.setChecked(req.ssl_verify if req.ssl_verify is not None else True) def apply_body(self, content: str): """Set body from AI suggestion and switch to Body tab.""" self.body_editor.setPlainText(content) self.tabs.setCurrentIndex(3) # Body tab def apply_params(self, content: str): """Parse key=value lines and merge into params table.""" pairs = {} for line in content.splitlines(): line = line.strip() if "=" in line and not line.startswith("#"): k, _, v = line.partition("=") if k.strip(): pairs[k.strip()] = v.strip() if pairs: existing = self.params_table.get_pairs() existing.update(pairs) self.params_table.set_pairs(existing) self.tabs.setCurrentIndex(0) # Params tab def apply_headers(self, content: str): """Parse Header: value lines and merge into headers table.""" pairs = {} for line in content.splitlines(): line = line.strip() if ":" in line and not line.startswith("#"): k, _, v = line.partition(":") if k.strip(): pairs[k.strip()] = v.strip() if pairs: existing = self.headers_table.get_pairs() existing.update(pairs) self.headers_table.set_pairs(existing) self.tabs.setCurrentIndex(1) # Headers tab def apply_test_script(self, content: str): """Set test script from AI suggestion and switch to Tests tab.""" self.test_editor.setPlainText(content) self.tabs.setCurrentIndex(5) # Tests tab