diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3c716e8..fac71b0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -185,9 +185,14 @@ src/ ├── sidebar/ # Right sidebar sub-package │ ├── sidebar_widget.py # RightSidebar (icon rail) + _FlyoutPanel │ ├── variables_panel.py # VariablesPanel — read-only variable display - │ └── snippet_panel.py # SnippetPanel — inline code snippet generator + │ ├── snippet_panel.py # SnippetPanel — inline code snippet generator + │ └── saved_responses/ # Saved responses sub-package + │ ├── panel.py # SavedResponsesPanel — saved example list/detail flyout + │ ├── search_filter.py # _PanelSearchFilterMixin — body search/filter + │ ├── helpers.py # Formatting helpers (body size, language detect, etc.) + │ └── delegate.py # Custom delegate for saved response list items ├── styling/ # Visual theming and icons - │ ├── theme.py # Palettes, colours, badge geometry, method_color() + │ ├── theme.py # Palettes, colours, badge geometry, method_color(), status_color() │ ├── theme_manager.py # ThemeManager — QPalette + QSettings │ ├── global_qss.py # build_global_qss() — global stylesheet builder │ └── icons.py # Phosphor font-glyph icon provider (phi()) @@ -280,7 +285,8 @@ tests/ ├── sidebar/ # Sidebar widget tests │ ├── test_sidebar.py │ ├── test_variables_panel.py - │ └── test_snippet_panel.py + │ ├── test_snippet_panel.py + │ └── test_saved_responses_panel.py ├── widgets/ # Shared component tests │ ├── test_code_editor.py │ ├── test_code_editor_folding.py diff --git a/.github/instructions/architecture.instructions.md b/.github/instructions/architecture.instructions.md index 69f7df2..0d23b4d 100644 --- a/.github/instructions/architecture.instructions.md +++ b/.github/instructions/architecture.instructions.md @@ -139,6 +139,11 @@ Key signals to know (always-on summary): - `NewItemPopup.new_request_clicked()` / `new_collection_clicked()` → emitted by the icon grid popup when tiles are clicked. - `RequestEditorWidget.send_requested()` → triggers HTTP send flow. +- `ResponseViewerWidget.save_response_requested(dict)` → saves the current live response. +- `ResponseViewerWidget.save_availability_changed(bool)` → refreshes right-sidebar saved-response affordances. +- `SavedResponsesPanel` emits `save_current_requested`, + `rename_requested`, `duplicate_requested`, and `delete_requested` — all + handled in `MainWindow` through `CollectionService`. - `ThemeManager.theme_changed()` → widgets refresh dynamic styles. - `VariablePopup` uses **class-level callbacks**, not signals — wired once in `MainWindow.__init__`. @@ -208,6 +213,40 @@ explicit `auth` dict. `{"type": "noauth"}` means "no authentication" and - `get_request_inherited_auth(request_id)` / `get_collection_inherited_auth(collection_id)` resolve the effective auth by walking ancestors. +### 7. Saved responses are now split across two UI surfaces + +- **Saving** a response remains a response-viewer action. The live response + viewer emits `save_response_requested(dict)` only when it has a live + `HttpResponseDict` loaded. +- **Browsing/managing** saved responses now lives in the right sidebar's + `SavedResponsesPanel`, alongside Variables and Snippets. +- The panel is fully self-contained: selecting a saved response shows its + details (headers, body, metadata) inline, with built-in search and filter. +- The old plain-text Saved tab in `ResponseViewerWidget` has been removed. + +### 8. Saved response data contract + +`CollectionService` now normalizes saved responses into `SavedResponseDict`: + +```python +class SavedResponseDict(TypedDict): + id: int + request_id: int + name: str + status: str | None + code: int | None + headers: list[dict[str, Any]] | None + body: str | None + preview_language: str | None + original_request: dict[str, Any] | None + created_at: str | None + body_size: int +``` + +`get_saved_responses_for_request()` orders rows newest-first by +`created_at DESC, id DESC`, and `CollectionService` formats `created_at` +into `%Y-%m-%d %H:%M` strings for the UI. + ## Repository and service reference > **Full repository function catalogues, service method tables, TypedDict @@ -249,3 +288,8 @@ explicit `auth` dict. `{"type": "noauth"}` means "no authentication" and `set_has_environment`) are classmethods that store callables on the **class itself**, not on an instance. They are wired once in `MainWindow.__init__` and survive popup hide/show cycles. + 9. **Saved response mutations are MainWindow-owned** — + `SavedResponsesPanel` is a read-only/browser widget. It never imports the + repository or service directly for mutations; it only emits signals to + `MainWindow`, which calls `CollectionService` and then refreshes the + sidebar state. diff --git a/.github/instructions/pyside6.instructions.md b/.github/instructions/pyside6.instructions.md index f01c1ad..a26864f 100644 --- a/.github/instructions/pyside6.instructions.md +++ b/.github/instructions/pyside6.instructions.md @@ -170,6 +170,7 @@ standard object names: | `smallPrimaryButton` | `QPushButton` | Compact accent button | | `outlineButton` | `QPushButton` | Border-only button | | `iconButton` | `QPushButton` | Icon-only square button (no padding) | +| `iconDangerButton` | `QPushButton` | Icon-only button with danger-red hover | | `linkButton` | `QPushButton` | Text-only accent link | | `flatAccentButton` | `QPushButton` | Borderless accent text | | `flatMutedButton` | `QPushButton` | Borderless muted text | diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 5c9eeb0..ec92874 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -139,10 +139,11 @@ tests/ │ ├── test_variable_line_edit.py │ ├── test_variable_popup.py │ └── test_variable_popup_local.py - ├── sidebar/ # Sidebar widget tests - │ ├── test_sidebar.py - │ ├── test_variables_panel.py - │ └── test_snippet_panel.py + ├── sidebar/ # Sidebar widget tests + │ ├── test_sidebar.py + │ ├── test_variables_panel.py + │ ├── test_snippet_panel.py + │ └── test_saved_responses_panel.py ├── collections/ # Collection sidebar tests │ ├── test_collection_header.py │ ├── test_collection_tree.py diff --git a/.github/skills/service-repository-reference/SKILL.md b/.github/skills/service-repository-reference/SKILL.md index f829c06..944d1d6 100644 --- a/.github/skills/service-repository-reference/SKILL.md +++ b/.github/skills/service-repository-reference/SKILL.md @@ -24,6 +24,9 @@ cross-layer data interchange. | `update_request_collection(request_id, new_collection_id)` | `None` | Move request | | `update_collection_parent(collection_id, new_parent_id)` | `None` | Move collection | | `save_response(request_id, ...)` | `int` | Persist a response snapshot, return its ID | +| `rename_saved_response(response_id, new_name)` | `None` | Rename a saved response | +| `delete_saved_response(response_id)` | `None` | Delete a saved response | +| `duplicate_saved_response(response_id)` | `int` | Copy a saved response, return new ID | | `update_collection(collection_id, **fields)` | `None` | Generic field update on a collection | | `update_request(request_id, **fields)` | `None` | Generic field update on a request | @@ -43,6 +46,7 @@ cross-layer data interchange. | `get_request_breadcrumb(request_id)` | `list[dict[str, Any]]` | Ancestor path for breadcrumb bar | | `get_collection_breadcrumb(collection_id)` | `list[dict[str, Any]]` | Ancestor path for collection breadcrumb | | `get_saved_responses_for_request(request_id)` | `list[dict[str, Any]]` | Saved responses for a request | +| `get_saved_response(response_id)` | `dict[str, Any] \| None` | Single saved response detail by ID | | `count_collection_requests(collection_id)` | `int` | Total request count in folder subtree | | `get_recent_requests_for_collection(collection_id, ...)` | `list[dict[str, Any]]` | Recently modified requests in subtree | @@ -93,8 +97,29 @@ directly to the repository with no added logic. | `get_collection_breadcrumb(collection_id)` | Passthrough | | `get_folder_request_count(collection_id)` | Passthrough | | `get_recent_requests(collection_id, ...)` | Passthrough | -| `get_saved_responses(request_id)` | Passthrough | +| `get_saved_responses(request_id)` | Formats `created_at` and `body_size` into `SavedResponseDict` | +| `get_saved_response(response_id)` | Formats one row into `SavedResponseDict` | | `save_response(request_id, ...)` | Passthrough | +| `rename_saved_response(response_id, new_name)` | `new_name.strip()`, rejects empty | +| `delete_saved_response(response_id)` | Logging only | +| `duplicate_saved_response(response_id)` | Logging only | + +### SavedResponseDict (`services/collection_service.py`) + +```python +class SavedResponseDict(TypedDict): + id: int + request_id: int + name: str + status: str | None + code: int | None + headers: list[dict[str, Any]] | None + body: str | None + preview_language: str | None + original_request: dict[str, Any] | None + created_at: str | None + body_size: int +``` ### ImportService diff --git a/.github/skills/signal-flow/SKILL.md b/.github/skills/signal-flow/SKILL.md index f972b8c..2f1c1c9 100644 --- a/.github/skills/signal-flow/SKILL.md +++ b/.github/skills/signal-flow/SKILL.md @@ -222,6 +222,39 @@ EnvironmentSelector.manage_requested ResponseViewerWidget.save_response_requested(dict) → MainWindow._on_save_response → CollectionService.save_response(request_id, ...) + +SavedResponsesPanel.save_current_requested() + → MainWindow._on_save_current_response_requested + → active ResponseViewerWidget.get_save_response_data() + → MainWindow._on_save_response + +SavedResponsesPanel.rename_requested(response_id) + → MainWindow._on_rename_saved_response + → CollectionService.rename_saved_response(response_id, new_name) + +SavedResponsesPanel.duplicate_requested(response_id) + → MainWindow._on_duplicate_saved_response + → CollectionService.duplicate_saved_response(response_id) + +SavedResponsesPanel.delete_requested(response_id) + → MainWindow._on_delete_saved_response + → QMessageBox.question(...) + → CollectionService.delete_saved_response(response_id) +``` + +### Saved responses sidebar flow + +``` +MainWindow._on_tab_changed / _refresh_sidebar + → RightSidebar.show_request_panels(...) + → RightSidebar.set_saved_response_context(...) + → SavedResponsesPanel.set_request_context(...) + → SavedResponsesPanel.set_saved_responses(...) + +ResponseViewerWidget.save_availability_changed(bool) + → _TabControllerMixin lambda + → MainWindow._refresh_sidebar() + → RightSidebar.set_saved_response_context(...can_save_current=...) ``` ### Folder editor flow diff --git a/src/database/models/collections/collection_query_repository.py b/src/database/models/collections/collection_query_repository.py index b05f63c..5beec3c 100644 --- a/src/database/models/collections/collection_query_repository.py +++ b/src/database/models/collections/collection_query_repository.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +from datetime import datetime from typing import Any from sqlalchemy import func as sa_func @@ -412,16 +413,38 @@ def get_saved_responses_for_request(request_id: int) -> list[dict[str, Any]]: from .model.saved_response_model import SavedResponseModel with get_session() as session: - stmt = select(SavedResponseModel).where(SavedResponseModel.request_id == request_id) + stmt = ( + select(SavedResponseModel) + .where(SavedResponseModel.request_id == request_id) + .order_by(SavedResponseModel.created_at.desc(), SavedResponseModel.id.desc()) + ) responses = list(session.execute(stmt).scalars().all()) - return [ - { - "id": sr.id, - "name": sr.name, - "status": sr.status, - "code": sr.code, - "headers": sr.headers, - "body": sr.body, - } - for sr in responses - ] + return [_saved_response_to_dict(sr) for sr in responses] + + +def get_saved_response(response_id: int) -> dict[str, Any] | None: + """Return one saved response as a dict, or ``None`` if missing.""" + from .model.saved_response_model import SavedResponseModel + + with get_session() as session: + response = session.get(SavedResponseModel, response_id) + if response is None: + return None + return _saved_response_to_dict(response) + + +def _saved_response_to_dict(response: Any) -> dict[str, Any]: + """Convert a ``SavedResponseModel`` into a plain dict.""" + created_at: datetime | None = response.created_at + return { + "id": response.id, + "request_id": response.request_id, + "name": response.name, + "status": response.status, + "code": response.code, + "headers": response.headers, + "body": response.body, + "preview_language": response.preview_language, + "original_request": response.original_request, + "created_at": created_at, + } diff --git a/src/database/models/collections/collection_repository.py b/src/database/models/collections/collection_repository.py index 901aa53..c4c4861 100644 --- a/src/database/models/collections/collection_repository.py +++ b/src/database/models/collections/collection_repository.py @@ -170,6 +170,8 @@ def save_response( code: int | None, headers: Any, body: str | None, + preview_language: str | None = None, + original_request: dict[str, Any] | None = None, ) -> int: """Save a response as a named example and return its ID.""" from .model.saved_response_model import SavedResponseModel @@ -182,12 +184,62 @@ def save_response( code=code, headers=headers, body=body, + preview_language=preview_language, + original_request=original_request, ) session.add(sr) session.flush() return sr.id +def rename_saved_response(response_id: int, new_name: str) -> None: + """Rename a saved response.""" + from .model.saved_response_model import SavedResponseModel + + with get_session() as session: + stmt = ( + update(SavedResponseModel) + .where(SavedResponseModel.id == response_id) + .values(name=new_name) + ) + session.execute(stmt) + + +def delete_saved_response(response_id: int) -> None: + """Delete a saved response by primary key.""" + from .model.saved_response_model import SavedResponseModel + + with get_session() as session: + response = session.get(SavedResponseModel, response_id) + if response is None: + raise ValueError(f"No saved response found with id={response_id}") + session.delete(response) + + +def duplicate_saved_response(response_id: int) -> int: + """Create a full copy of a saved response and return the new ID.""" + from .model.saved_response_model import SavedResponseModel + + with get_session() as session: + source = session.get(SavedResponseModel, response_id) + if source is None: + raise ValueError(f"No saved response found with id={response_id}") + + duplicate = SavedResponseModel( + request_id=source.request_id, + name=f"{source.name} (copy)", + status=source.status, + code=source.code, + headers=source.headers, + body=source.body, + preview_language=source.preview_language, + original_request=source.original_request, + ) + session.add(duplicate) + session.flush() + return duplicate.id + + # Columns on CollectionModel that may be updated via update_collection(). _EDITABLE_COLLECTION_FIELDS = { "description", diff --git a/src/services/collection_service.py b/src/services/collection_service.py index abadfbc..fd860b4 100644 --- a/src/services/collection_service.py +++ b/src/services/collection_service.py @@ -7,6 +7,8 @@ from __future__ import annotations import logging +from collections.abc import Mapping +from datetime import datetime from typing import Any, TypedDict from database.models.collections.collection_query_repository import ( @@ -21,6 +23,7 @@ get_request_by_id, get_request_inherited_auth, get_request_variable_chain, + get_saved_response, get_saved_responses_for_request, ) from database.models.collections.collection_repository import ( @@ -28,8 +31,11 @@ create_new_request, delete_collection, delete_request, + delete_saved_response, + duplicate_saved_response, rename_collection, rename_request, + rename_saved_response, save_response, update_collection, update_collection_parent, @@ -63,6 +69,85 @@ class RequestLoadDict(TypedDict, total=False): auth: dict[str, Any] | None +class SavedResponseDict(TypedDict): + """Full saved response payload used by the sidebar UI.""" + + id: int + request_id: int + name: str + status: str | None + code: int | None + headers: list[dict[str, Any]] | None + body: str | None + preview_language: str | None + original_request: dict[str, Any] | None + created_at: str | None + body_size: int + + +def _normalize_header_list(headers: Any) -> list[dict[str, Any]] | None: + """Return saved-response headers in the canonical ``[{key, value}]`` shape.""" + if headers is None: + return None + + if isinstance(headers, Mapping): + return [ + {"key": str(key), "value": "" if value is None else str(value)} + for key, value in headers.items() + ] + + if isinstance(headers, str): + lines = [line.strip() for line in headers.splitlines() if line.strip()] + if not lines: + return None + normalized: list[dict[str, Any]] = [] + for line in lines: + key, separator, value = line.partition(":") + normalized.append( + { + "key": key.strip() if separator else line, + "value": value.strip() if separator else "", + } + ) + return normalized + + if not isinstance(headers, list): + return None + + normalized = [] + for header in headers: + if isinstance(header, Mapping): + raw_key = header.get("key") or header.get("name") or header.get("header") or "" + raw_value = header.get("value") + normalized.append( + { + "key": str(raw_key), + "value": "" if raw_value is None else str(raw_value), + } + ) + continue + if isinstance(header, str): + key, separator, value = header.partition(":") + normalized.append( + { + "key": key.strip() if separator else header, + "value": value.strip() if separator else "", + } + ) + return normalized or None + + +def _normalize_request_snapshot(original_request: Any) -> dict[str, Any] | None: + """Normalize saved original-request snapshots for read-only UI rendering.""" + if not isinstance(original_request, Mapping): + return None + + normalized = dict(original_request) + if "headers" in normalized: + normalized["headers"] = _normalize_header_list(normalized.get("headers")) + return normalized + + class CollectionService: """Service that wraps repository calls with validation and logging. @@ -313,9 +398,20 @@ def get_recent_requests( # Saved responses # ------------------------------------------------------------------ @staticmethod - def get_saved_responses(request_id: int) -> list[dict[str, Any]]: + def get_saved_responses(request_id: int) -> list[SavedResponseDict]: """Return all saved responses (examples) for a request.""" - return get_saved_responses_for_request(request_id) + return [ + CollectionService._format_saved_response_dict(item) + for item in get_saved_responses_for_request(request_id) + ] + + @staticmethod + def get_saved_response(response_id: int) -> SavedResponseDict | None: + """Return one saved response (example) with full metadata.""" + item = get_saved_response(response_id) + if item is None: + return None + return CollectionService._format_saved_response_dict(item) @staticmethod def save_response( @@ -325,8 +421,63 @@ def save_response( code: int | None, headers: Any, body: str | None, + preview_language: str | None = None, + original_request: dict[str, Any] | None = None, ) -> int: """Save a response as a named example and return its ID.""" - result = save_response(request_id, name, status, code, headers, body) + result = save_response( + request_id, + name, + status, + code, + _normalize_header_list(headers), + body, + preview_language=preview_language, + original_request=_normalize_request_snapshot(original_request), + ) logger.info("Saved response for request id=%s", request_id) return result + + @staticmethod + def rename_saved_response(response_id: int, new_name: str) -> None: + """Rename an existing saved response.""" + clean_name = new_name.strip() + if not clean_name: + raise ValueError("Saved response name must not be empty") + rename_saved_response(response_id, clean_name) + logger.info("Renamed saved response id=%s to %r", response_id, clean_name) + + @staticmethod + def delete_saved_response(response_id: int) -> None: + """Delete a saved response.""" + delete_saved_response(response_id) + logger.info("Deleted saved response id=%s", response_id) + + @staticmethod + def duplicate_saved_response(response_id: int) -> int: + """Duplicate a saved response and return the new ID.""" + result = duplicate_saved_response(response_id) + logger.info("Duplicated saved response id=%s -> %s", response_id, result) + return result + + @staticmethod + def _format_saved_response_dict(item: dict[str, Any]) -> SavedResponseDict: + """Normalize repository saved-response rows for UI consumption.""" + body = item.get("body") + created_at = item.get("created_at") + created_text = ( + created_at.strftime("%Y-%m-%d %H:%M") if isinstance(created_at, datetime) else None + ) + return { + "id": int(item["id"]), + "request_id": int(item["request_id"]), + "name": str(item.get("name") or "Untitled Response"), + "status": item.get("status"), + "code": item.get("code"), + "headers": _normalize_header_list(item.get("headers")), + "body": body, + "preview_language": item.get("preview_language"), + "original_request": _normalize_request_snapshot(item.get("original_request")), + "created_at": created_text, + "body_size": len(body.encode("utf-8")) if isinstance(body, str) else 0, + } diff --git a/src/ui/main_window/send_pipeline.py b/src/ui/main_window/send_pipeline.py index 148df5a..b821d14 100644 --- a/src/ui/main_window/send_pipeline.py +++ b/src/ui/main_window/send_pipeline.py @@ -42,6 +42,10 @@ class _SendPipelineMixin: def _current_tab_context(self) -> TabContext | None: ... + if TYPE_CHECKING: + + def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: ... + def _on_send_request(self) -> None: """Send the current request on a background thread.""" ctx: TabContext | None = self._current_tab_context() @@ -152,6 +156,7 @@ def _on_send_finished(self, data: dict) -> None: data.get("status_code"), data.get("elapsed_ms", 0), ) + self._refresh_sidebar() def _on_send_error(self, message: str) -> None: """Handle an error from the HTTP send worker.""" @@ -171,6 +176,7 @@ def _on_send_error(self, message: str) -> None: editor._method_combo.currentText(), editor._url_input.text(), ) + self._refresh_sidebar() def _cancel_send(self) -> None: """Cancel the in-flight HTTP request.""" diff --git a/src/ui/main_window/tab_controller.py b/src/ui/main_window/tab_controller.py index 4e76c17..57d081e 100644 --- a/src/ui/main_window/tab_controller.py +++ b/src/ui/main_window/tab_controller.py @@ -68,7 +68,7 @@ def _refresh_variable_map( request_id: int | None, local_overrides: dict | None = ..., ) -> None: ... - def _refresh_sidebar(self) -> None: ... + def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: ... def _schedule_sidebar_snippet_refresh(self) -> None: ... # ------------------------------------------------------------------ @@ -177,6 +177,7 @@ def _create_tab( editor.dirty_changed.connect(self._sync_save_btn) editor.request_changed.connect(lambda _: self._schedule_sidebar_snippet_refresh()) viewer.save_response_requested.connect(self._on_save_response) + viewer.save_availability_changed.connect(lambda _enabled: self._refresh_sidebar()) # Now switch to the tab (triggers _on_tab_changed safely) self._tab_bar.setCurrentIndex(idx) @@ -248,10 +249,6 @@ def _on_tab_changed(self, index: int) -> None: ) else: self._breadcrumb_bar.clear() - # Load saved responses - if ctx.request_id is not None: - saved = CollectionService.get_saved_responses(ctx.request_id) - ctx.response_viewer.load_saved_responses(saved) # Refresh variable map for highlighting and tooltips self._refresh_variable_map(ctx.editor, ctx.request_id, ctx.local_overrides) else: @@ -263,8 +260,9 @@ def _on_tab_changed(self, index: int) -> None: self._breadcrumb_bar.clear() self._save_btn.setVisible(False) - # Refresh right sidebar for the active tab - self._refresh_sidebar() + # Refresh right sidebar for the active tab using the same context + # that drove the stacked-widget switch. + self._refresh_sidebar(ctx) # ------------------------------------------------------------------ # Tab close diff --git a/src/ui/main_window/variable_controller.py b/src/ui/main_window/variable_controller.py index 9354f7b..ee643aa 100644 --- a/src/ui/main_window/variable_controller.py +++ b/src/ui/main_window/variable_controller.py @@ -186,9 +186,10 @@ def _on_add_unresolved_variable( # ------------------------------------------------------------------ # Right-sidebar helpers # ------------------------------------------------------------------ - def _refresh_sidebar(self) -> None: + def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: """Update the right sidebar panels for the active tab.""" - ctx = self._current_tab_context() + if ctx is None: + ctx = self._current_tab_context() env_id = self._env_selector.current_environment_id() has_env = env_id is not None @@ -248,6 +249,22 @@ def _refresh_sidebar(self) -> None: body=sub(data.get("body") or "", flat_vars) or None, auth=auth, ) + request_name = None + saved_responses = [] + is_persisted_request = ctx.request_id is not None + if ctx.request_id is not None: + from services.collection_service import CollectionService + + request = CollectionService.get_request(ctx.request_id) + request_name = request.name if request is not None else None + saved_responses = CollectionService.get_saved_responses(ctx.request_id) + self._right_sidebar.set_saved_response_context( + request_id=ctx.request_id, + request_name=request_name, + items=saved_responses, + can_save_current=ctx.response_viewer.has_live_response(), + is_persisted_request=is_persisted_request, + ) def _schedule_sidebar_snippet_refresh(self) -> None: """Debounce snippet refresh (300 ms) on request editor changes.""" @@ -299,7 +316,11 @@ def _toggle_right_sidebar(self) -> None: if self._right_sidebar.panel_open: self._right_sidebar._close_panel() else: - self._right_sidebar.open_panel("variables") + ctx = self._current_tab_context() + if ctx is None: + return + self._refresh_sidebar(ctx) + self._right_sidebar.open_default_panel() def _on_snippet_shortcut(self) -> None: """Open the sidebar with the snippet panel visible.""" diff --git a/src/ui/main_window/window.py b/src/ui/main_window/window.py index 6cd218e..c71ca22 100644 --- a/src/ui/main_window/window.py +++ b/src/ui/main_window/window.py @@ -13,7 +13,9 @@ from PySide6.QtWidgets import ( QHBoxLayout, + QInputDialog, QMainWindow, + QMessageBox, QPushButton, QSizePolicy, QSplitter, @@ -113,6 +115,18 @@ def __init__(self, theme_manager: ThemeManager | None = None) -> None: # Wire environment editor self._env_selector.manage_requested.connect(self._on_manage_environments) + self._right_sidebar.saved_responses_panel.save_current_requested.connect( + self._on_save_current_response_requested + ) + self._right_sidebar.saved_responses_panel.rename_requested.connect( + self._on_rename_saved_response + ) + self._right_sidebar.saved_responses_panel.duplicate_requested.connect( + self._on_duplicate_saved_response + ) + self._right_sidebar.saved_responses_panel.delete_requested.connect( + self._on_delete_saved_response + ) # Refresh variable highlighting when the environment changes self._env_selector.environment_changed.connect(self._on_environment_changed) @@ -592,13 +606,74 @@ def _on_save_response(self, data: dict) -> None: ctx = self._current_tab_context() if ctx is None or ctx.request_id is None: return + code = data.get("code") + status = data.get("status") or "" + default_name = f"{code} {status}".strip() or "Saved Response" + name, accepted = QInputDialog.getText( + self, + "Save Response", + "Example name:", + text=default_name, + ) + if not accepted: + return + clean_name = name.strip() or default_name + request_data = ctx.editor.get_request_data() CollectionService.save_response( ctx.request_id, - name="Saved Response", - status=data.get("status"), - code=None, + name=clean_name, + status=status or None, + code=code if isinstance(code, int) else None, headers=data.get("headers"), body=data.get("body"), + preview_language=data.get("preview_language"), + original_request=request_data, + ) + self._refresh_sidebar() + + def _on_save_current_response_requested(self) -> None: + """Save the current live response from the active response viewer.""" + ctx = self._current_tab_context() + if ctx is None: + return + payload = ctx.response_viewer.get_save_response_data() + if payload is None: + return + self._on_save_response(payload) + + def _on_rename_saved_response(self, response_id: int) -> None: + """Rename a saved response from the sidebar panel.""" + detail = CollectionService.get_saved_response(response_id) + if detail is None: + return + name, accepted = QInputDialog.getText( + self, + "Rename Saved Response", + "New name:", + text=detail["name"], + ) + if not accepted: + return + CollectionService.rename_saved_response(response_id, name) + self._refresh_sidebar() + self._right_sidebar.saved_responses_panel.select_response(response_id) + + def _on_duplicate_saved_response(self, response_id: int) -> None: + """Duplicate a saved response from the sidebar panel.""" + new_id = CollectionService.duplicate_saved_response(response_id) + self._refresh_sidebar() + self._right_sidebar.saved_responses_panel.select_response(new_id) + + def _on_delete_saved_response(self, response_id: int) -> None: + """Delete a saved response from the sidebar panel after confirmation.""" + confirm = QMessageBox.question( + self, + "Delete Saved Response", + "Delete this saved response?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - saved = CollectionService.get_saved_responses(ctx.request_id) - ctx.response_viewer.load_saved_responses(saved) + if confirm != QMessageBox.StandardButton.Yes: + return + CollectionService.delete_saved_response(response_id) + self._refresh_sidebar() diff --git a/src/ui/request/response_viewer/viewer_widget.py b/src/ui/request/response_viewer/viewer_widget.py index b08e998..f609490 100644 --- a/src/ui/request/response_viewer/viewer_widget.py +++ b/src/ui/request/response_viewer/viewer_widget.py @@ -84,6 +84,7 @@ class ResponseViewerWidget(_SearchFilterMixin, QWidget): """ save_response_requested = Signal(dict) + save_availability_changed = Signal(bool) def __init__(self, parent: QWidget | None = None) -> None: """Initialise the response viewer layout.""" @@ -130,6 +131,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._save_response_btn.setObjectName("flatMutedButton") self._save_response_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._save_response_btn.clicked.connect(self._on_save_response) + self._save_response_btn.setEnabled(False) status_row.addWidget(self._save_response_btn) self._status_bar_widget = QWidget() @@ -149,6 +151,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._last_status_text: str = "" self._last_status_color: str = "" self._last_elapsed_ms: float = 0.0 + self._last_live_response: dict | None = None # -- Progress bar (loading state) ----------------------------- self._progress_bar = QProgressBar() @@ -256,13 +259,6 @@ def __init__(self, parent: QWidget | None = None) -> None: self._cookies_edit.setObjectName("monoEdit") self._tabs.addTab(self._cookies_edit, "Cookies") - # Saved responses tab - self._saved_list = QTextEdit() - self._saved_list.setReadOnly(True) - self._saved_list.setPlaceholderText("No saved responses") - self._saved_list.setObjectName("monoEdit") - self._tabs.addTab(self._saved_list, "Saved") - root.addWidget(self._tabs, 1) # -- Empty state label ---------------------------------------- @@ -308,6 +304,7 @@ def show_loading(self) -> None: def show_error(self, message: str) -> None: """Display an error message (e.g. connection refused).""" self._error_label.setText(f"Could not send request\n\n{message}") + self._set_save_enabled(False) self._set_state("error") def load_response(self, data: dict) -> None: @@ -323,6 +320,30 @@ def load_response(self, data: dict) -> None: self.show_error(f"{data['error']}{suffix}") return + self._last_live_response = dict(data) + self._set_save_enabled(True) + + self._render_response_data(data) + + def has_live_response(self) -> bool: + """Return whether a live response is currently available for saving.""" + return self._last_live_response is not None + + def get_save_response_data(self) -> dict | None: + """Return the current live response payload suitable for saving.""" + if not self.has_live_response() or self._last_live_response is None: + return None + preview_language = self._detect_preview_language(self._last_live_response) + return { + "status": self._last_live_response.get("status_text"), + "code": self._last_live_response.get("status_code"), + "body": self._last_live_response.get("body"), + "headers": self._last_live_response.get("headers"), + "preview_language": preview_language, + } + + def _render_response_data(self, data: dict) -> None: + """Render response data into the viewer widgets.""" self._set_state("response") # Status code @@ -377,6 +398,7 @@ def load_response(self, data: dict) -> None: def clear(self) -> None: """Reset to the empty state.""" + self._set_save_enabled(False) self._set_state("empty") self._status_label.setText("") self._time_label.setText("") @@ -385,6 +407,7 @@ def clear(self) -> None: self._headers_edit.clear() self._cookies_edit.clear() self._error_label.setText("") + self._last_live_response = None self._raw_body = "" self._filtered_body = "" self._is_filtered = False @@ -474,31 +497,50 @@ def _try_pretty_xml(text: str) -> str: def _on_save_response(self) -> None: """Emit the save_response_requested signal with current response data.""" - if not self._raw_body and not self._status_label.text(): + data = self.get_save_response_data() + if data is None: return - data = { - "status": self._status_label.text(), - "body": self._raw_body, - "headers": self._headers_edit.toPlainText(), - } self.save_response_requested.emit(data) - def load_saved_responses(self, responses: list[dict]) -> None: - """Populate the Saved tab with a list of saved response dicts.""" - if not responses: - self._saved_list.setPlainText("No saved responses") - return - lines = [] - for resp in responses: - name = resp.get("name", "Untitled") - code = resp.get("code", "") - status = resp.get("status", "") - lines.append(f"--- {name} ({code} {status}) ---") - body = resp.get("body", "") - if body: - lines.append(body[:500]) - lines.append("") - self._saved_list.setPlainText("\n".join(lines)) + def _set_save_enabled(self, enabled: bool) -> None: + """Update Save Response button enabled state and notify listeners.""" + self._save_response_btn.setEnabled(enabled) + self.save_availability_changed.emit(enabled) + + @staticmethod + def _detect_preview_language(data: dict) -> str | None: + """Guess the preview language from the response headers.""" + headers = data.get("headers") or [] + content_type = "" + for header in headers: + key = str(header.get("key", "")).lower() + if key == "content-type": + content_type = str(header.get("value", "")).lower() + break + if "json" in content_type: + return "json" + if "xml" in content_type: + return "xml" + if "html" in content_type: + return "html" + # Fallback: sniff body content + body = str(data.get("body") or "").strip() + if body and body[0] in ("{", "["): + import json + + try: + json.loads(body) + return "json" + except (json.JSONDecodeError, ValueError): + pass + lower = body[:100].lower() + if lower.startswith(" None: + """Paint the list row with a coloured badge and two text lines.""" + # 1. Let the style draw selection / hover background. + self.initStyleOption(option, index) + style = option.widget.style() if option.widget else None + if style: + opt = QStyleOptionViewItem(option) + opt.text = "" + opt.icon = QIcon() + style.drawControl( + QStyle.ControlElement.CE_ItemViewItem, + opt, + painter, + option.widget, + ) + + code = index.data(ROLE_RESPONSE_CODE) + name = index.data(ROLE_RESPONSE_NAME) or "" + meta = index.data(ROLE_RESPONSE_META) or "" + rect: QRect = option.rect # type: ignore[assignment] + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # 2. Status-code badge + badge_x = rect.left() + _LEFT_PADDING + badge_y = rect.top() + _TOP_PADDING + 1 + badge_rect = QRect(badge_x, badge_y, _BADGE_WIDTH, _BADGE_HEIGHT) + + bg_colour = QColor(status_color(code)) + painter.setBrush(bg_colour) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect( + badge_rect, + BADGE_BORDER_RADIUS, + BADGE_BORDER_RADIUS, + ) + + badge_font = QFont(painter.font()) + badge_font.setPixelSize(BADGE_FONT_SIZE) + badge_font.setBold(True) + painter.setFont(badge_font) + painter.setPen(QPen(QColor("#ffffff"))) + badge_text = str(code) if code is not None else "\u2014" + painter.drawText(badge_rect, Qt.AlignmentFlag.AlignCenter, badge_text) + + # 3. Response name (right of badge, first line) + name_x = badge_rect.right() + _BADGE_NAME_SPACING + available_w = rect.right() - name_x - _LEFT_PADDING + name_rect = QRect(name_x, rect.top() + _TOP_PADDING - 1, available_w, 20) + + name_font = QFont(painter.font()) + name_font.setPixelSize(12) + name_font.setBold(True) + + state = option.state # type: ignore[assignment] + palette = option.palette # type: ignore[assignment] + if state & QStyle.StateFlag.State_Selected: + text_color = palette.highlightedText().color() + else: + text_color = palette.text().color() + + painter.setPen(QPen(text_color)) + painter.setFont(name_font) + fm = QFontMetrics(name_font) + elided = fm.elidedText(name, Qt.TextElideMode.ElideRight, available_w) + painter.drawText(name_rect, Qt.AlignmentFlag.AlignVCenter, elided) + + # 4. Metadata (second line, full width, muted) + meta_y = rect.top() + _TOP_PADDING + _BADGE_HEIGHT + _LINE_SPACING + meta_rect = QRect( + rect.left() + _LEFT_PADDING, + meta_y, + rect.width() - _LEFT_PADDING * 2, + 16, + ) + + meta_font = QFont(painter.font()) + meta_font.setPixelSize(11) + meta_font.setBold(False) + if state & QStyle.StateFlag.State_Selected: + meta_color = palette.highlightedText().color() + else: + meta_color = QColor(theme.COLOR_TEXT_MUTED) + painter.setPen(QPen(meta_color)) + painter.setFont(meta_font) + fm_meta = QFontMetrics(meta_font) + elided_meta = fm_meta.elidedText( + meta, + Qt.TextElideMode.ElideRight, + meta_rect.width(), + ) + painter.drawText(meta_rect, Qt.AlignmentFlag.AlignVCenter, elided_meta) + + painter.restore() + + def sizeHint( + self, + option: QStyleOptionViewItem, + index: QModelIndex | QPersistentModelIndex, + ) -> QSize: + """Return a fixed row height for all items.""" + return QSize(option.rect.width(), _ROW_HEIGHT) diff --git a/src/ui/sidebar/saved_responses/helpers.py b/src/ui/sidebar/saved_responses/helpers.py new file mode 100644 index 0000000..9048bcc --- /dev/null +++ b/src/ui/sidebar/saved_responses/helpers.py @@ -0,0 +1,108 @@ +"""Helper functions for formatting saved response data.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +def format_body_size(size_bytes: int) -> str: + """Format response-body byte size as a compact label.""" + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + return f"{size_bytes / (1024 * 1024):.2f} MB" + + +def detect_body_language(body: str) -> str | None: + """Sniff a response body and guess its language from content.""" + import json + + text = body.strip() + if not text: + return None + if text[0] in ("{", "["): + try: + json.loads(text) + return "json" + except (json.JSONDecodeError, ValueError): + pass + lower = text[:100].lower() + if lower.startswith(" str: + """Attempt to pretty-print JSON; return original text on failure.""" + import json + + try: + parsed = json.loads(text) + return json.dumps(parsed, indent=4, ensure_ascii=False) + except (json.JSONDecodeError, TypeError): + return text + + +def try_pretty_xml(text: str) -> str: + """Attempt to pretty-print XML/HTML; return original text on failure.""" + try: + import xml.dom.minidom + + dom = xml.dom.minidom.parseString(text) + return dom.toprettyxml(indent=" ") + except Exception: + return text + + +def format_json_text(data: dict[str, Any], *, pretty: bool) -> str: + """Serialize request snapshot data as compact or pretty JSON.""" + import json + + if pretty: + return json.dumps(data, indent=4, ensure_ascii=False) + return json.dumps(data, separators=(",", ":"), ensure_ascii=False) + + +def format_code_text(text: str, language: str, *, pretty: bool) -> str: + """Format response text for the read-only code editors.""" + if not pretty or not text: + return text + if language == "json": + return try_pretty_json(text) + if language in {"xml", "html"}: + return try_pretty_xml(text) + pretty_json = try_pretty_json(text) + if pretty_json != text: + return pretty_json + return try_pretty_xml(text) + + +def build_row_meta(item: Mapping[str, Any]) -> str: + """Return a metadata summary line for a saved response list row.""" + meta_parts: list[str] = [] + if item["created_at"]: + meta_parts.append(item["created_at"]) + if item["preview_language"]: + meta_parts.append(item["preview_language"].upper()) + if item["body_size"]: + meta_parts.append(format_body_size(item["body_size"])) + return " \u00b7 ".join(meta_parts) + + +def format_headers(headers: Any) -> str: + """Render headers as read-only text, or empty string if absent.""" + if not headers: + return "" + if isinstance(headers, dict): + return "\n".join(f"{key}: {value}" for key, value in headers.items()) + if isinstance(headers, str): + return headers + return "\n".join( + f"{header.get('key', '')}: {header.get('value', '')}" + for header in headers + if isinstance(header, dict) + ) diff --git a/src/ui/sidebar/saved_responses/panel.py b/src/ui/sidebar/saved_responses/panel.py new file mode 100644 index 0000000..e3c7d8e --- /dev/null +++ b/src/ui/sidebar/saved_responses/panel.py @@ -0,0 +1,608 @@ +"""Saved responses panel — list/detail flyout for browsing saved examples.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from PySide6.QtCore import Qt, Signal, SignalInstance +from PySide6.QtGui import QKeySequence, QShortcut +from PySide6.QtWidgets import ( + QApplication, + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, + QSplitter, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from ui.sidebar.saved_responses.delegate import ( + ROLE_RESPONSE_CODE, + ROLE_RESPONSE_META, + ROLE_RESPONSE_NAME, + SavedResponseDelegate, +) +from ui.sidebar.saved_responses.helpers import ( + build_row_meta, + detect_body_language, + format_body_size, + format_code_text, + format_headers, + format_json_text, +) +from ui.sidebar.saved_responses.search_filter import _PanelSearchFilterMixin +from ui.styling.icons import phi +from ui.styling.theme import status_color +from ui.widgets.code_editor import CodeEditorWidget + +if TYPE_CHECKING: + from services.collection_service import SavedResponseDict + + +class SavedResponsesPanel(_PanelSearchFilterMixin, QWidget): + """List/detail sidebar panel for saved request responses.""" + + save_current_requested = Signal() + refresh_requested = Signal() + rename_requested = Signal(int) + duplicate_requested = Signal(int) + delete_requested = Signal(int) + + def __init__(self, parent: QWidget | None = None) -> None: + """Build the panel UI and start in the no-request state.""" + super().__init__(parent) + + self._request_id: int | None = None + self._request_name: str = "" + self._items: list[SavedResponseDict] = [] + self._items_by_id: dict[int, SavedResponseDict] = {} + self._current_response_id: int | None = None + self._body_raw_text: str = "" + self._body_language: str = "text" + self._snapshot_raw_data: Any = None + self._body_view_mode: str = "Pretty" + self._snapshot_view_mode: str = "Pretty" + + root = QVBoxLayout(self) + root.setContentsMargins(8, 4, 8, 8) + root.setSpacing(6) + + header_row = QHBoxLayout() + header_row.setContentsMargins(0, 0, 0, 0) + header_row.setSpacing(6) + + self._subtitle_label = QLabel("") + self._subtitle_label.setObjectName("mutedLabel") + header_row.addWidget(self._subtitle_label, 1) + + self._refresh_btn = self._make_icon_btn( + "arrow-clockwise", + "Refresh saved responses", + "iconButton", + ) + self._refresh_btn.clicked.connect(self.refresh_requested.emit) + header_row.addWidget(self._refresh_btn) + + self._save_current_btn = QPushButton("Save Current") + self._save_current_btn.setObjectName("smallPrimaryButton") + self._save_current_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._save_current_btn.clicked.connect(self.save_current_requested.emit) + header_row.addWidget(self._save_current_btn) + + root.addLayout(header_row) + + self._state_label = QLabel() + self._state_label.setObjectName("emptyStateLabel") + self._state_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._state_label.setWordWrap(True) + root.addWidget(self._state_label, 1) + + self._content_splitter = QSplitter(Qt.Orientation.Vertical) + self._content_splitter.setChildrenCollapsible(False) + root.addWidget(self._content_splitter, 1) + + self._list_widget = QListWidget() + self._list_widget.itemSelectionChanged.connect(self._on_selection_changed) + self._list_widget.setItemDelegate(SavedResponseDelegate(self._list_widget)) + self._content_splitter.addWidget(self._list_widget) + + detail_host = QWidget() + detail_layout = QVBoxLayout(detail_host) + detail_layout.setContentsMargins(0, 0, 0, 0) + detail_layout.setSpacing(6) + + detail_header = QHBoxLayout() + detail_header.setContentsMargins(0, 0, 0, 0) + detail_header.setSpacing(6) + + self._status_badge = QLabel() + self._status_badge.setObjectName("savedResponseStatusBadge") + self._status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._status_badge.setFixedHeight(22) + self._status_badge.setMinimumWidth(42) + detail_header.addWidget(self._status_badge) + + summary_col = QVBoxLayout() + summary_col.setContentsMargins(0, 0, 0, 0) + summary_col.setSpacing(0) + + self._detail_name = QLabel("Select a saved response") + self._detail_name.setObjectName("sectionLabel") + summary_col.addWidget(self._detail_name) + + self._detail_meta = QLabel("") + self._detail_meta.setObjectName("mutedLabel") + summary_col.addWidget(self._detail_meta) + + detail_header.addLayout(summary_col, 1) + + self._rename_btn = self._make_icon_btn("pencil-simple", "Rename", "iconButton") + self._rename_btn.clicked.connect(lambda: self._emit_signal(self.rename_requested)) + detail_header.addWidget(self._rename_btn) + + self._duplicate_btn = self._make_icon_btn("copy", "Duplicate", "iconButton") + self._duplicate_btn.clicked.connect(lambda: self._emit_signal(self.duplicate_requested)) + detail_header.addWidget(self._duplicate_btn) + + self._delete_btn = self._make_icon_btn("trash", "Delete", "iconDangerButton") + self._delete_btn.clicked.connect(lambda: self._emit_signal(self.delete_requested)) + detail_header.addWidget(self._delete_btn) + + detail_layout.addLayout(detail_header) + + self._detail_tabs = QTabWidget() + self._detail_tabs.tabBar().setCursor(Qt.CursorShape.PointingHandCursor) + detail_layout.addWidget(self._detail_tabs, 1) + + self._build_body_tab() + self._build_headers_tab() + self._build_snapshot_tab() + + self._content_splitter.addWidget(detail_host) + self._content_splitter.setSizes([180, 280]) + + self.clear() + + # -- Tab construction helpers -------------------------------------- + + def _build_body_tab(self) -> None: + """Construct the Body tab with format combo, filter, search, and editor.""" + body_tab = QWidget() + body_layout = QVBoxLayout(body_tab) + body_layout.setContentsMargins(0, 4, 0, 0) + body_layout.setSpacing(6) + body_toolbar = QHBoxLayout() + body_toolbar.setContentsMargins(0, 0, 0, 0) + body_toolbar.setSpacing(6) + self._body_view_combo = QComboBox() + self._body_view_combo.addItems(["Pretty", "Raw"]) + self._body_view_combo.setFixedWidth(90) + self._body_view_combo.currentTextChanged.connect(self._refresh_body_view) + body_toolbar.addWidget(self._body_view_combo) + body_toolbar.addStretch() + self._body_edit = CodeEditorWidget(read_only=True) + self._body_copy_btn = self._make_copy_btn(lambda: self._copy_editor(self._body_edit)) + + self._body_filter_btn = QPushButton() + self._body_filter_btn.setIcon(phi("funnel")) + self._body_filter_btn.setToolTip("Filter response (JSONPath / XPath)") + self._body_filter_btn.setObjectName("iconButton") + self._body_filter_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._body_filter_btn.setCheckable(True) + self._body_filter_btn.setFixedSize(28, 28) + self._body_filter_btn.clicked.connect(self._toggle_filter) + body_toolbar.addWidget(self._body_filter_btn) + + self._body_search_btn = QPushButton() + self._body_search_btn.setIcon(phi("magnifying-glass")) + find_hint = QKeySequence(QKeySequence.StandardKey.Find).toString( + QKeySequence.SequenceFormat.NativeText, + ) + self._body_search_btn.setToolTip(f"Search in response ({find_hint})") + self._body_search_btn.setObjectName("iconButton") + self._body_search_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._body_search_btn.setCheckable(True) + self._body_search_btn.setFixedSize(28, 28) + self._body_search_btn.clicked.connect(self._toggle_search) + body_toolbar.addWidget(self._body_search_btn) + + body_toolbar.addWidget(self._body_copy_btn) + body_layout.addLayout(body_toolbar) + + # Filter bar (hidden by default) + self._filter_bar = QWidget() + filter_layout = QHBoxLayout(self._filter_bar) + filter_layout.setContentsMargins(0, 4, 0, 0) + filter_layout.setSpacing(4) + self._filter_input = QLineEdit() + self._filter_input.setPlaceholderText("Filter using JSONPath: $.store.books") + self._filter_input.returnPressed.connect(self._apply_filter) + filter_layout.addWidget(self._filter_input, 1) + self._filter_error_label = QLabel() + self._filter_error_label.setObjectName("mutedLabel") + self._filter_error_label.hide() + filter_layout.addWidget(self._filter_error_label) + self._filter_apply_btn = self._make_icon_btn("play", "Apply filter", "iconButton") + self._filter_apply_btn.clicked.connect(self._apply_filter) + filter_layout.addWidget(self._filter_apply_btn) + self._filter_clear_btn = self._make_icon_btn("x", "Clear filter", "iconButton") + self._filter_clear_btn.clicked.connect(self._clear_filter) + self._filter_clear_btn.hide() + filter_layout.addWidget(self._filter_clear_btn) + self._filter_bar.hide() + body_layout.addWidget(self._filter_bar) + self._is_filtered = False + self._filter_expression = "" + + # Search bar (hidden by default) + self._search_bar = QWidget() + search_layout = QHBoxLayout(self._search_bar) + search_layout.setContentsMargins(0, 4, 0, 0) + search_layout.setSpacing(4) + self._search_input = QLineEdit() + self._search_input.setPlaceholderText("Find in response\u2026") + self._search_input.textChanged.connect(self._on_search_text_changed) + search_layout.addWidget(self._search_input, 1) + self._search_count_label = QLabel("") + self._search_count_label.setObjectName("mutedLabel") + search_layout.addWidget(self._search_count_label) + prev_btn = self._make_icon_btn("caret-up", "Previous match", "iconButton") + prev_btn.setFixedSize(24, 24) + prev_btn.clicked.connect(self._search_prev) + search_layout.addWidget(prev_btn) + next_btn = self._make_icon_btn("caret-down", "Next match", "iconButton") + next_btn.setFixedSize(24, 24) + next_btn.clicked.connect(self._search_next) + search_layout.addWidget(next_btn) + close_btn = self._make_icon_btn("x", "Close search", "iconButton") + close_btn.setFixedSize(24, 24) + close_btn.clicked.connect(self._close_search) + search_layout.addWidget(close_btn) + self._search_bar.hide() + body_layout.addWidget(self._search_bar) + self._find_shortcut = QShortcut(QKeySequence.StandardKey.Find, self) + self._find_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + self._find_shortcut.activated.connect(self._toggle_search) + self._search_matches: list[int] = [] + self._search_index: int = -1 + + self._body_empty_label = self._make_empty_label("No response body") + body_layout.addWidget(self._body_empty_label, 1) + body_layout.addWidget(self._body_edit, 1) + self._detail_tabs.addTab(body_tab, "Body") + + def _build_headers_tab(self) -> None: + """Construct the Headers tab.""" + headers_tab = QWidget() + headers_layout = QVBoxLayout(headers_tab) + headers_layout.setContentsMargins(0, 0, 0, 0) + headers_layout.setSpacing(6) + headers_toolbar = QHBoxLayout() + headers_toolbar.setContentsMargins(0, 0, 0, 0) + headers_toolbar.setSpacing(6) + headers_toolbar.addStretch() + self._headers_edit = CodeEditorWidget(read_only=True) + self._headers_copy_btn = self._make_copy_btn(lambda: self._copy_editor(self._headers_edit)) + headers_toolbar.addWidget(self._headers_copy_btn) + headers_layout.addLayout(headers_toolbar) + self._headers_empty_label = self._make_empty_label("No response headers") + headers_layout.addWidget(self._headers_empty_label, 1) + headers_layout.addWidget(self._headers_edit, 1) + self._detail_tabs.addTab(headers_tab, "Headers") + + def _build_snapshot_tab(self) -> None: + """Construct the Request Snapshot tab.""" + snapshot_tab = QWidget() + snapshot_layout = QVBoxLayout(snapshot_tab) + snapshot_layout.setContentsMargins(0, 0, 0, 0) + snapshot_layout.setSpacing(6) + snapshot_toolbar = QHBoxLayout() + snapshot_toolbar.setContentsMargins(0, 0, 0, 0) + snapshot_toolbar.setSpacing(6) + self._snapshot_view_combo = QComboBox() + self._snapshot_view_combo.addItems(["Pretty", "Raw"]) + self._snapshot_view_combo.setFixedWidth(90) + self._snapshot_view_combo.currentTextChanged.connect(self._refresh_snapshot_view) + snapshot_toolbar.addWidget(self._snapshot_view_combo) + snapshot_toolbar.addStretch() + self._snapshot_edit = CodeEditorWidget(read_only=True) + self._snapshot_copy_btn = self._make_copy_btn( + lambda: self._copy_editor(self._snapshot_edit) + ) + snapshot_toolbar.addWidget(self._snapshot_copy_btn) + snapshot_layout.addLayout(snapshot_toolbar) + self._snapshot_empty_label = self._make_empty_label("No request snapshot available") + snapshot_layout.addWidget(self._snapshot_empty_label, 1) + snapshot_layout.addWidget(self._snapshot_edit, 1) + self._detail_tabs.addTab(snapshot_tab, "Request Snapshot") + + # -- Public API ---------------------------------------------------- + + def set_request_context(self, request_id: int | None, request_name: str | None) -> None: + """Set the active request context shown in the panel header.""" + self._request_id = request_id + self._request_name = request_name or "" + self._subtitle_label.setText(self._request_name) + + def set_live_response_available(self, available: bool) -> None: + """Enable or disable the Save Current action.""" + self._save_current_btn.setEnabled(available and self._request_id is not None) + + def show_request_required_state(self, message: str) -> None: + """Show a contextual empty state when saved responses are unavailable.""" + self._state_label.setText(message) + self._state_label.show() + self._content_splitter.hide() + self._set_detail_enabled(False) + self._save_current_btn.setEnabled(False) + self._refresh_btn.setEnabled(False) + + def show_empty_examples_state(self) -> None: + """Show the empty state for a persisted request with no examples.""" + self._state_label.setText( + "No saved responses for this request.\n\n" + "Save the next live response to keep an example here." + ) + self._state_label.show() + self._content_splitter.hide() + self._set_detail_enabled(False) + self._refresh_btn.setEnabled(self._request_id is not None) + + def set_saved_responses(self, items: list[SavedResponseDict]) -> None: + """Populate the list and detail pane with saved response items.""" + self._items = items + self._items_by_id = {item["id"]: item for item in items} + self._refresh_btn.setEnabled(self._request_id is not None) + self._list_widget.clear() + + if not items: + self._current_response_id = None + self.show_empty_examples_state() + return + + for item in items: + row = QListWidgetItem() + row.setData(Qt.ItemDataRole.UserRole, item["id"]) + row.setData(ROLE_RESPONSE_CODE, item["code"]) + row.setData(ROLE_RESPONSE_NAME, item["name"]) + row.setData(ROLE_RESPONSE_META, build_row_meta(item)) + row.setToolTip(item["name"]) + self._list_widget.addItem(row) + + self._state_label.hide() + self._content_splitter.show() + self._select_response(self._current_response_id or items[0]["id"]) + + def select_response(self, response_id: int) -> None: + """Select a saved response by ID if it exists in the current list.""" + self._select_response(response_id) + + def clear(self) -> None: + """Reset the panel to its no-request state.""" + self._request_id = None + self._request_name = "" + self._subtitle_label.setText("") + self._items = [] + self._items_by_id = {} + self._current_response_id = None + self._list_widget.clear() + self._body_raw_text = "" + self._body_language = "text" + self._snapshot_raw_data = None + self._body_view_mode = "Pretty" + self._snapshot_view_mode = "Pretty" + self._set_combo_text(self._body_view_combo, self._body_view_mode) + self._set_combo_text(self._snapshot_view_combo, self._snapshot_view_mode) + self._body_edit.set_language("text") + self._body_edit.set_text("") + self._body_edit.hide() + self._body_empty_label.show() + self._headers_edit.set_language("text") + self._headers_edit.set_text("") + self._headers_edit.hide() + self._headers_empty_label.show() + self._snapshot_edit.set_language("text") + self._snapshot_edit.set_text("") + self._snapshot_edit.hide() + self._snapshot_empty_label.show() + self._status_badge.setText("") + self._status_badge.setStyleSheet("") + self._detail_name.setText("Select a saved response") + self._detail_meta.setText("") + self._reset_search_filter() + self.show_request_required_state("Open a saved request to browse its saved responses.") + + # -- Private helpers ----------------------------------------------- + + def _select_response(self, response_id: int | None) -> None: + """Select the matching item in the list and populate the detail pane.""" + if response_id is None: + self._set_detail_enabled(False) + return + for index in range(self._list_widget.count()): + item = self._list_widget.item(index) + if item.data(Qt.ItemDataRole.UserRole) == response_id: + self._list_widget.setCurrentRow(index) + self._populate_detail(self._items_by_id[response_id]) + return + self._set_detail_enabled(False) + + def _on_selection_changed(self) -> None: + """Update the detail pane when the list selection changes.""" + item = self._list_widget.currentItem() + if item is None: + self._current_response_id = None + self._set_detail_enabled(False) + return + response_id = item.data(Qt.ItemDataRole.UserRole) + if not isinstance(response_id, int): + self._set_detail_enabled(False) + return + self._current_response_id = response_id + detail = self._items_by_id.get(response_id) + if detail is not None: + self._populate_detail(detail) + + def _populate_detail(self, item: SavedResponseDict) -> None: + """Render the selected saved response in the detail pane.""" + code = item["code"] + status = item["status"] or "" + + # 1. Status badge + badge_text = str(code) if code is not None else "\u2014" + colour = status_color(code) + self._status_badge.setText(badge_text) + self._status_badge.setStyleSheet( + f"background: {colour}; color: #ffffff; " + f"padding: 2px 8px; border-radius: 3px; " + f"font-weight: bold; font-size: 11px;" + ) + + # 2. Name and enriched metadata + self._detail_name.setText(item["name"]) + meta_parts: list[str] = [] + if status: + meta_parts.append(status) + if item["created_at"]: + meta_parts.append(item["created_at"]) + if item["preview_language"]: + meta_parts.append(item["preview_language"].upper()) + if item["body_size"]: + meta_parts.append(format_body_size(item["body_size"])) + self._detail_meta.setText(" \u00b7 ".join(meta_parts)) + + # 3. Body tab — reset search/filter, then render + self._reset_search_filter() + self._body_raw_text = item["body"] or "" + self._body_language = ( + item["preview_language"] or detect_body_language(item["body"] or "") or "text" + ) + self._set_combo_text(self._body_view_combo, self._body_view_mode) + self._refresh_body_view() + + # 4. Headers tab + headers_text = format_headers(item["headers"]) + if headers_text: + self._headers_empty_label.hide() + self._headers_edit.show() + self._headers_edit.set_language("text") + self._headers_edit.set_text(headers_text) + else: + self._headers_edit.hide() + self._headers_empty_label.show() + + # 5. Snapshot tab + self._snapshot_raw_data = item["original_request"] + self._set_combo_text(self._snapshot_view_combo, self._snapshot_view_mode) + self._refresh_snapshot_view() + + self._set_detail_enabled(True) + + def _set_detail_enabled(self, enabled: bool) -> None: + """Enable or disable detail actions and tabs.""" + self._detail_tabs.setEnabled(enabled) + self._rename_btn.setEnabled(enabled) + self._duplicate_btn.setEnabled(enabled) + self._delete_btn.setEnabled(enabled) + + def _make_copy_btn(self, slot: object) -> QPushButton: + """Create a clipboard copy icon button connected to *slot*.""" + return self._make_icon_btn("clipboard", "Copy to clipboard", "iconButton", slot) + + @staticmethod + def _make_icon_btn( + icon_name: str, + tooltip: str, + obj_name: str, + slot: object = None, + ) -> QPushButton: + """Create a 28x28 icon button with optional click slot.""" + btn = QPushButton() + btn.setIcon(phi(icon_name)) + btn.setObjectName(obj_name) + btn.setFixedSize(28, 28) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setToolTip(tooltip) + if slot is not None: + btn.clicked.connect(slot) + return btn + + @staticmethod + def _make_empty_label(text: str) -> QLabel: + """Create a centred empty-state label for a detail tab.""" + label = QLabel(text) + label.setObjectName("emptyStateLabel") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + return label + + def _emit_signal(self, signal: SignalInstance) -> None: + """Emit *signal* with the current response id, if one is selected.""" + if self._current_response_id is not None: + signal.emit(self._current_response_id) + + def _copy_editor(self, editor: CodeEditorWidget) -> None: + """Copy the given editor's text to the system clipboard.""" + clipboard = QApplication.clipboard() + if clipboard is not None: + clipboard.setText(editor.toPlainText()) + + def _refresh_body_view(self, _mode: str | None = None) -> None: + """Render the saved response body using the selected view mode.""" + self._body_view_mode = self._body_view_combo.currentText() + if not self._body_raw_text: + self._body_edit.hide() + self._body_empty_label.show() + return + + self._body_empty_label.hide() + self._body_edit.show() + language = self._body_language or "text" + body_text = self._body_raw_text + if self._body_view_mode == "Pretty": + body_text = format_code_text(body_text, language, pretty=True) + self._body_edit.set_language(language) + + # Re-apply active filter if one exists + if self._is_filtered and self._filter_expression: + self._run_filter(self._filter_expression, body_text) + return + + self._body_edit.set_text(body_text) + + def _refresh_snapshot_view(self, _mode: str | None = None) -> None: + """Render the saved request snapshot using the selected view mode.""" + self._snapshot_view_mode = self._snapshot_view_combo.currentText() + if self._snapshot_raw_data is None: + self._snapshot_edit.hide() + self._snapshot_empty_label.show() + return + + self._snapshot_empty_label.hide() + self._snapshot_edit.show() + if isinstance(self._snapshot_raw_data, dict): + self._snapshot_edit.set_language("json") + self._snapshot_edit.set_text( + format_json_text( + self._snapshot_raw_data, + pretty=self._snapshot_view_mode == "Pretty", + ) + ) + return + + snapshot_text = str(self._snapshot_raw_data) + if self._snapshot_view_mode == "Pretty": + snapshot_text = format_code_text(snapshot_text, "text", pretty=True) + self._snapshot_edit.set_language("text") + self._snapshot_edit.set_text(snapshot_text) + + @staticmethod + def _set_combo_text(combo: QComboBox, text: str) -> None: + """Set combo text without triggering a redundant re-render.""" + combo.blockSignals(True) + combo.setCurrentText(text) + combo.blockSignals(False) diff --git a/src/ui/sidebar/saved_responses/search_filter.py b/src/ui/sidebar/saved_responses/search_filter.py new file mode 100644 index 0000000..ef3f7b6 --- /dev/null +++ b/src/ui/sidebar/saved_responses/search_filter.py @@ -0,0 +1,262 @@ +"""Search and filter mixin for the saved responses panel body editor.""" + +from __future__ import annotations + +from PySide6.QtGui import QColor, QTextCharFormat, QTextCursor +from PySide6.QtWidgets import QLabel, QLineEdit, QPushButton, QTextEdit, QWidget + +from ui.styling.theme import COLOR_WARNING +from ui.widgets.code_editor import CodeEditorWidget + + +class _PanelSearchFilterMixin: + """Mixin that adds body search and filter methods to SavedResponsesPanel. + + Expects the host class to provide: + + - ``_body_edit: CodeEditorWidget`` + - ``_body_language: str`` + - ``_body_raw_text: str`` + - ``_body_view_mode: str`` + - ``_body_filter_btn: QPushButton`` + - ``_body_search_btn: QPushButton`` + - ``_filter_bar: QWidget`` + - ``_filter_input: QLineEdit`` + - ``_filter_error_label: QLabel`` + - ``_filter_apply_btn: QPushButton`` + - ``_filter_clear_btn: QPushButton`` + - ``_search_bar: QWidget`` + - ``_search_input: QLineEdit`` + - ``_search_count_label: QLabel`` + - ``_search_matches: list[int]`` + - ``_search_index: int`` + - ``_is_filtered: bool`` + - ``_filter_expression: str`` + - ``_refresh_body_view()`` + """ + + # -- type stubs for mypy ------------------------------------------ + _body_edit: CodeEditorWidget + _body_language: str + _body_raw_text: str + _body_view_mode: str + _body_filter_btn: QPushButton + _body_search_btn: QPushButton + _filter_bar: QWidget + _filter_input: QLineEdit + _filter_error_label: QLabel + _filter_apply_btn: QPushButton + _filter_clear_btn: QPushButton + _search_bar: QWidget + _search_input: QLineEdit + _search_count_label: QLabel + _search_matches: list[int] + _search_index: int + _is_filtered: bool + _filter_expression: str + + def _refresh_body_view(self, _mode: str | None = None) -> None: ... + + # -- Search -------------------------------------------------------- + + def _reset_search_filter(self) -> None: + """Reset search and filter UI state to defaults.""" + self._close_search() + self._is_filtered = False + self._filter_expression = "" + self._filter_bar.hide() + self._body_filter_btn.setChecked(False) + self._filter_input.clear() + self._filter_error_label.hide() + self._filter_clear_btn.hide() + self._filter_apply_btn.show() + + def _toggle_search(self) -> None: + """Show or hide the body search bar.""" + if not self._search_bar.isHidden(): + self._close_search() + else: + self._search_bar.show() + self._body_search_btn.setChecked(True) + self._search_input.setFocus() + self._search_input.selectAll() + + def _close_search(self) -> None: + """Hide the search bar and clear highlights.""" + self._search_bar.hide() + self._body_search_btn.setChecked(False) + self._search_input.clear() + self._body_edit.set_search_selections([]) + + def _on_search_text_changed(self, text: str) -> None: + """Highlight all occurrences of *text* in the body.""" + self._body_edit.set_search_selections([]) + self._search_matches = [] + self._search_index = -1 + + if not text: + self._search_count_label.setText("") + return + + body_text = self._body_edit.toPlainText() + start = 0 + while True: + idx = body_text.find(text, start) + if idx == -1: + break + self._search_matches.append(idx) + start = idx + 1 + + if not self._search_matches: + self._search_count_label.setText("No results") + return + + fmt = QTextCharFormat() + fmt.setBackground(QColor(COLOR_WARNING)) + selections: list[QTextEdit.ExtraSelection] = [] + for pos in self._search_matches: + sel = QTextEdit.ExtraSelection() + cur = QTextCursor(self._body_edit.document()) + cur.setPosition(pos) + cur.setPosition(pos + len(text), QTextCursor.MoveMode.KeepAnchor) + sel.cursor = cur + sel.format = fmt + selections.append(sel) + self._body_edit.set_search_selections(selections) + + self._search_index = 0 + self._goto_match() + + def _search_next(self) -> None: + """Move to the next search match.""" + if not self._search_matches: + return + self._search_index = (self._search_index + 1) % len(self._search_matches) + self._goto_match() + + def _search_prev(self) -> None: + """Move to the previous search match.""" + if not self._search_matches: + return + self._search_index = (self._search_index - 1) % len(self._search_matches) + self._goto_match() + + def _goto_match(self) -> None: + """Scroll to the current search match and update the counter.""" + if self._search_index < 0 or self._search_index >= len(self._search_matches): + return + pos = self._search_matches[self._search_index] + text = self._search_input.text() + cursor = self._body_edit.textCursor() + cursor.setPosition(pos) + cursor.setPosition(pos + len(text), QTextCursor.MoveMode.KeepAnchor) + self._body_edit.setTextCursor(cursor) + self._body_edit.ensureCursorVisible() + total = len(self._search_matches) + self._search_count_label.setText(f"{self._search_index + 1} of {total}") + + # -- Filter -------------------------------------------------------- + + def _toggle_filter(self) -> None: + """Show or hide the filter bar.""" + if not self._filter_bar.isHidden(): + self._filter_bar.hide() + self._body_filter_btn.setChecked(False) + else: + self._filter_bar.show() + self._body_filter_btn.setChecked(True) + self._update_filter_placeholder() + self._filter_input.setFocus() + + def _update_filter_placeholder(self) -> None: + """Set the filter input placeholder based on the body language.""" + lang = self._body_language or "text" + if lang in ("xml", "html"): + self._filter_input.setPlaceholderText("Filter using XPath: //item") + else: + self._filter_input.setPlaceholderText("Filter using JSONPath: $.store.books") + + def _apply_filter(self) -> None: + """Evaluate the filter expression and display matching results.""" + from ui.sidebar.saved_responses.helpers import format_code_text + + expr = self._filter_input.text().strip() + if not expr: + return + + self._filter_error_label.hide() + body = self._body_raw_text + if self._body_view_mode == "Pretty": + body = format_code_text(body, self._body_language or "text", pretty=True) + + self._run_filter(expr, body) + + def _run_filter(self, expr: str, body: str) -> None: + """Run *expr* against *body* and display results in the editor.""" + is_xml = self._body_language in ("xml", "html") + + try: + result = _eval_xpath(expr, body) if is_xml else _eval_jsonpath(expr, body) + except Exception as exc: + self._filter_error_label.setText(str(exc)[:120]) + self._filter_error_label.show() + return + + if result is None: + self._filter_error_label.setText("No matches") + self._filter_error_label.show() + return + + self._is_filtered = True + self._filter_expression = expr + self._filter_error_label.hide() + self._filter_apply_btn.hide() + self._filter_clear_btn.show() + self._body_edit.set_text(result) + + def _clear_filter(self) -> None: + """Clear the active filter and restore the original body.""" + was_filtered = self._is_filtered + self._is_filtered = False + self._filter_expression = "" + self._filter_error_label.hide() + self._filter_clear_btn.hide() + self._filter_apply_btn.show() + if was_filtered: + self._refresh_body_view() + + +# -- Standalone filter evaluators (not methods) ------------------------- + + +def _eval_jsonpath(expr: str, body: str) -> str | None: + """Evaluate a JSONPath expression against a JSON *body* string.""" + import json + + from jsonpath_ng import parse as jsonpath_parse # type: ignore[import-untyped] + + data = json.loads(body) + matches = jsonpath_parse(expr).find(data) + if not matches: + return None + values = [m.value for m in matches] + if len(values) == 1: + return json.dumps(values[0], indent=4, ensure_ascii=False) + return json.dumps(values, indent=4, ensure_ascii=False) + + +def _eval_xpath(expr: str, body: str) -> str | None: + """Evaluate an XPath expression against an XML/HTML *body* string.""" + from lxml import etree + + root = etree.fromstring(body.encode("utf-8")) + results = root.xpath(expr) + if not results: + return None + parts: list[str] = [] + for node in results: + if isinstance(node, etree._Element): + parts.append(etree.tostring(node, pretty_print=True, encoding="unicode")) + else: + parts.append(str(node)) + return "\n".join(parts).rstrip() diff --git a/src/ui/sidebar/sidebar_widget.py b/src/ui/sidebar/sidebar_widget.py index 51f45a8..e7a64a7 100644 --- a/src/ui/sidebar/sidebar_widget.py +++ b/src/ui/sidebar/sidebar_widget.py @@ -28,6 +28,8 @@ QWidget, ) +from services.collection_service import SavedResponseDict +from ui.sidebar.saved_responses.panel import SavedResponsesPanel from ui.sidebar.snippet_panel import SnippetPanel from ui.sidebar.variables_panel import VariablesPanel from ui.styling.icons import phi @@ -43,7 +45,7 @@ class _FlyoutPanel(QWidget): """Collapsible content panel placed as its own splitter child.""" def __init__(self, parent: QWidget | None = None) -> None: - """Build title bar, variables panel and snippet panel.""" + """Build title bar and all flyout content panels.""" super().__init__(parent) self.setObjectName("sidebarPanelArea") self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) @@ -80,14 +82,17 @@ def __init__(self, parent: QWidget | None = None) -> None: # Content panels self.variables_panel = VariablesPanel() self.snippet_panel = SnippetPanel() + self.saved_responses_panel = SavedResponsesPanel() self.snippet_panel.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding, ) layout.addWidget(self.variables_panel, 1) layout.addWidget(self.snippet_panel, 1) + layout.addWidget(self.saved_responses_panel, 1) self.variables_panel.hide() self.snippet_panel.hide() + self.saved_responses_panel.hide() def minimumSizeHint(self) -> QSize: """Enforce a readable minimum width for the flyout.""" @@ -124,6 +129,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._title_label = self._flyout.title_label self._variables_panel = self._flyout.variables_panel self._snippet_panel = self._flyout.snippet_panel + self._saved_responses_panel = self._flyout.saved_responses_panel self._close_btn.clicked.connect(self._close_panel) # --- Rail layout ---------------------------------------------- @@ -136,9 +142,12 @@ def __init__(self, parent: QWidget | None = None) -> None: "Variables", ) self._snippet_btn = self._make_rail_button("code", "Code snippet") + self._saved_btn = self._make_rail_button("floppy-disk-back", "Saved responses") self._snippet_btn.hide() + self._saved_btn.hide() rail_layout.addWidget(self._var_btn) rail_layout.addWidget(self._snippet_btn) + rail_layout.addWidget(self._saved_btn) rail_layout.addStretch() # State @@ -154,6 +163,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self._snippet_btn.clicked.connect( lambda: self._toggle_panel("snippet"), ) + self._saved_btn.clicked.connect( + lambda: self._toggle_panel("saved_responses"), + ) # Keep a reference for the ``_rail`` attribute used by tests. @property @@ -212,6 +224,11 @@ def snippet_panel(self) -> SnippetPanel: """Return the snippet panel widget.""" return self._snippet_panel + @property + def saved_responses_panel(self) -> SavedResponsesPanel: + """Return the saved responses panel widget.""" + return self._saved_responses_panel + @property def active_panel(self) -> str | None: """Return the key of the currently open panel, or *None*.""" @@ -235,11 +252,13 @@ def show_request_panels( auth: dict | None = None, ) -> None: """Configure the sidebar for a request tab.""" - self._available_panels = {"variables", "snippet"} + self._available_panels = {"variables", "snippet", "saved_responses"} self._default_panel = "snippet" self._var_btn.setEnabled(True) self._snippet_btn.show() self._snippet_btn.setEnabled(True) + self._saved_btn.show() + self._saved_btn.setEnabled(True) self._variables_panel.load_variables( variables, @@ -267,29 +286,63 @@ def show_folder_panels( self._default_panel = "variables" self._var_btn.setEnabled(True) self._snippet_btn.hide() + self._saved_btn.hide() self._variables_panel.load_variables( variables, has_environment=has_environment, ) - if self._active_panel == "snippet": + if self._active_panel in {"snippet", "saved_responses"}: self._close_panel() + def set_saved_response_context( + self, + *, + request_id: int | None, + request_name: str | None, + items: list[SavedResponseDict], + can_save_current: bool, + is_persisted_request: bool, + ) -> None: + """Populate the saved responses panel for the active request context.""" + self._saved_btn.setVisible(True) + self._saved_btn.setEnabled(is_persisted_request) + self._saved_responses_panel.set_request_context(request_id, request_name) + self._saved_responses_panel.set_live_response_available(can_save_current) + if not is_persisted_request: + if self._active_panel == "saved_responses": + self._close_panel() + self._saved_responses_panel.show_request_required_state( + "Save the request first to store and browse saved responses." + ) + return + self._saved_responses_panel.set_saved_responses(items) + def clear(self) -> None: """Reset the sidebar to an empty state (no tab open).""" self._available_panels = set() self._var_btn.setEnabled(False) self._snippet_btn.hide() + self._saved_btn.hide() self._close_panel() self._variables_panel.clear() self._snippet_panel.clear() + self._saved_responses_panel.clear() def open_panel(self, panel: str) -> None: """Programmatically open a specific panel by key.""" if panel in self._available_panels: self._show_panel(panel) + def open_default_panel(self) -> None: + """Open the most relevant available panel for the current context.""" + panel = self._last_panel if self._last_panel in self._available_panels else None + if panel is None: + panel = self._default_panel + if panel is not None: + self._show_panel(panel) + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @@ -318,10 +371,16 @@ def _show_panel(self, panel: str) -> None: self._last_panel = panel self._variables_panel.setVisible(panel == "variables") self._snippet_panel.setVisible(panel == "snippet") + self._saved_responses_panel.setVisible(panel == "saved_responses") self._var_btn.setChecked(panel == "variables") self._snippet_btn.setChecked(panel == "snippet") + self._saved_btn.setChecked(panel == "saved_responses") self._title_label.setText( - "Variables" if panel == "variables" else "Code snippet", + "Variables" + if panel == "variables" + else "Code snippet" + if panel == "snippet" + else "Saved Responses", ) self._flyout.show() self._expand_flyout() @@ -331,8 +390,10 @@ def _close_panel(self) -> None: self._active_panel = None self._variables_panel.hide() self._snippet_panel.hide() + self._saved_responses_panel.hide() self._var_btn.setChecked(False) self._snippet_btn.setChecked(False) + self._saved_btn.setChecked(False) self._collapse_flyout() def _expand_flyout(self) -> None: @@ -372,8 +433,10 @@ def _on_splitter_moved(self, _pos: int, _index: int) -> None: self._active_panel = None self._variables_panel.hide() self._snippet_panel.hide() + self._saved_responses_panel.hide() self._var_btn.setChecked(False) self._snippet_btn.setChecked(False) + self._saved_btn.setChecked(False) if flyout_width > 0 and not self._active_panel: # User expanded the flyout by dragging — open a panel. @@ -387,9 +450,15 @@ def _on_splitter_moved(self, _pos: int, _index: int) -> None: self._last_panel = panel self._variables_panel.setVisible(panel == "variables") self._snippet_panel.setVisible(panel == "snippet") + self._saved_responses_panel.setVisible(panel == "saved_responses") self._var_btn.setChecked(panel == "variables") self._snippet_btn.setChecked(panel == "snippet") + self._saved_btn.setChecked(panel == "saved_responses") self._title_label.setText( - "Variables" if panel == "variables" else "Code snippet", + "Variables" + if panel == "variables" + else "Code snippet" + if panel == "snippet" + else "Saved Responses", ) self._flyout.show() diff --git a/src/ui/styling/global_qss.py b/src/ui/styling/global_qss.py index 3b69de4..3baa36a 100644 --- a/src/ui/styling/global_qss.py +++ b/src/ui/styling/global_qss.py @@ -76,6 +76,10 @@ def build_global_qss(p: ThemePalette) -> str: padding: 8px; }} + QLabel#savedResponseStatusBadge {{ + margin-top: 6px; + }} + /* ---- Inputs ------------------------------------------------- */ QLineEdit, QComboBox {{ background: {p["input_bg"]}; @@ -194,6 +198,18 @@ def build_global_qss(p: ThemePalette) -> str: background: {"rgba(255,255,255,0.12)" if p is DARK_PALETTE else "rgba(0,0,0,0.10)"}; border-color: {p["accent"]}; }} + QPushButton[objectName="iconDangerButton"] {{ + border: 1px solid {p["border"]}; + padding: 0px; + border-radius: 4px; + background: transparent; + color: {p["text_muted"]}; + }} + QPushButton[objectName="iconDangerButton"]:hover {{ + background: {"rgba(244,71,71,0.12)" if p is DARK_PALETTE else "rgba(231,76,60,0.10)"}; + color: {p["danger"]}; + border-color: {p["danger"]}; + }} QPushButton[objectName="linkButton"] {{ color: {p["accent"]}; border: none; diff --git a/src/ui/styling/theme.py b/src/ui/styling/theme.py index 522c44e..7f2f629 100644 --- a/src/ui/styling/theme.py +++ b/src/ui/styling/theme.py @@ -428,3 +428,16 @@ def method_color(method: str) -> str: def method_short_label(method: str) -> str: """Return a compact badge label for a given HTTP method.""" return METHOD_SHORT_LABELS.get(method.upper(), method.upper()[:3]) + + +def status_color(code: int | None) -> str: + """Return the theme colour for an HTTP status code.""" + if code is None: + return COLOR_MUTED + if code < 300: + return COLOR_SUCCESS + if code < 400: + return COLOR_WARNING + if code < 500: + return COLOR_DELETE + return COLOR_DANGER diff --git a/tests/ui/request/test_response_viewer.py b/tests/ui/request/test_response_viewer.py index f4f6015..3cbbcec 100644 --- a/tests/ui/request/test_response_viewer.py +++ b/tests/ui/request/test_response_viewer.py @@ -5,6 +5,7 @@ from PySide6.QtWidgets import QApplication from ui.request.response_viewer import ResponseViewerWidget +from ui.request.response_viewer.viewer_widget import ResponseViewerWidget as _RVW # -- Sample data used across tests ------------------------------------ @@ -291,7 +292,7 @@ def test_beautify_empty_body_noop(self, qapp: QApplication, qtbot) -> None: class TestResponseViewerSaveResponse: - """Tests for the Save response button and saved responses tab.""" + """Tests for the Save response button and saved-example mode.""" def test_save_emits_signal(self, qapp: QApplication, qtbot) -> None: """Clicking Save emits save_response_requested with current data.""" @@ -310,8 +311,10 @@ def test_save_emits_signal(self, qapp: QApplication, qtbot) -> None: viewer._on_save_response() data = blocker.args[0] - assert "200" in data["status"] + assert data["code"] == 200 + assert data["status"] == "OK" assert '{"ok": true}' in data["body"] + assert data["headers"] == [{"key": "X-Test", "value": "1"}] def test_save_no_response_noop(self, qapp: QApplication, qtbot) -> None: """Save does nothing when no response is loaded.""" @@ -323,35 +326,6 @@ def test_save_no_response_noop(self, qapp: QApplication, qtbot) -> None: viewer._on_save_response() assert emitted == [] - def test_load_saved_responses(self, qapp: QApplication, qtbot) -> None: - """Saved responses populate the Saved tab.""" - viewer = ResponseViewerWidget() - qtbot.addWidget(viewer) - responses = [ - {"name": "Success", "code": 200, "status": "OK", "body": '{"result": 1}'}, - {"name": "Error", "code": 500, "status": "Server Error", "body": "fail"}, - ] - viewer.load_saved_responses(responses) - text = viewer._saved_list.toPlainText() - assert "Success" in text - assert "Error" in text - assert "200" in text - assert "500" in text - - def test_load_saved_responses_empty(self, qapp: QApplication, qtbot) -> None: - """Empty list shows placeholder text in Saved tab.""" - viewer = ResponseViewerWidget() - qtbot.addWidget(viewer) - viewer.load_saved_responses([]) - assert "No saved responses" in viewer._saved_list.toPlainText() - - def test_saved_tab_exists(self, qapp: QApplication, qtbot) -> None: - """Response viewer has a Saved tab.""" - viewer = ResponseViewerWidget() - qtbot.addWidget(viewer) - tab_titles = [viewer._tabs.tabText(i) for i in range(viewer._tabs.count())] - assert "Saved" in tab_titles - class TestResponseViewerPopups: """Tests for the click-triggered popup panels.""" @@ -422,3 +396,43 @@ def test_network_icon_exists(self, qapp: QApplication, qtbot) -> None: qtbot.addWidget(viewer) assert viewer._network_icon is not None assert viewer._network_icon.parent() is viewer._status_bar_widget + + +class TestDetectPreviewLanguage: + """Tests for body-content sniffing in _detect_preview_language.""" + + def test_json_body_without_content_type(self) -> None: + """JSON body is detected when no Content-Type header is present.""" + result = _RVW._detect_preview_language({"headers": [], "body": '{"error": "bad"}'}) + assert result == "json" + + def test_xml_body_without_content_type(self) -> None: + """XML body is detected when no Content-Type header is present.""" + result = _RVW._detect_preview_language( + {"headers": [], "body": ""} + ) + assert result == "xml" + + def test_html_body_without_content_type(self) -> None: + """HTML body is detected when no Content-Type header is present.""" + result = _RVW._detect_preview_language( + {"headers": [], "body": ""} + ) + assert result == "html" + + def test_content_type_takes_precedence(self) -> None: + """Content-Type header wins over body sniffing.""" + result = _RVW._detect_preview_language( + { + "headers": [{"key": "Content-Type", "value": "text/plain"}], + "body": '{"json": true}', + } + ) + # Body looks like JSON but Content-Type says text → sniffing returns json + assert result == "json" + + def test_no_body_no_content_type(self) -> None: + """No body and no Content-Type returns None.""" + result = _RVW._detect_preview_language({"headers": [], "body": ""}) + assert result is None + assert result is None diff --git a/tests/ui/sidebar/test_saved_responses_panel.py b/tests/ui/sidebar/test_saved_responses_panel.py new file mode 100644 index 0000000..5de834a --- /dev/null +++ b/tests/ui/sidebar/test_saved_responses_panel.py @@ -0,0 +1,457 @@ +"""Tests for the SavedResponsesPanel widget.""" + +from __future__ import annotations + +from typing import cast + +from PySide6.QtWidgets import QApplication + +from services.collection_service import SavedResponseDict +from ui.sidebar.saved_responses.delegate import ( + ROLE_RESPONSE_CODE, + ROLE_RESPONSE_META, + ROLE_RESPONSE_NAME, +) +from ui.sidebar.saved_responses.helpers import detect_body_language +from ui.sidebar.saved_responses.panel import SavedResponsesPanel + + +class TestSavedResponsesPanel: + """Tests for the saved responses list/detail sidebar panel.""" + + def test_construction(self, qapp: QApplication, qtbot) -> None: + """Panel can be instantiated and starts in a request-required state.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + assert "Open a saved request" in panel._state_label.text() + + def test_set_saved_responses_populates_list(self, qapp: QApplication, qtbot) -> None: + """Saved response items populate the list and first detail entry.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Success", + "status": "OK", + "code": 200, + "headers": [{"key": "Content-Type", "value": "application/json"}], + "body": '{"ok": true}', + "preview_language": "json", + "original_request": {"method": "GET", "url": "https://example.com"}, + "created_at": "2026-03-12 10:00", + "body_size": 12, + } + ] + ) + assert panel._list_widget.count() == 1 + assert "Success" in panel._detail_name.text() + assert '"ok": true' in panel._body_edit.toPlainText() + + def test_body_view_can_switch_between_pretty_and_raw(self, qapp: QApplication, qtbot) -> None: + """Saved response body can switch between pretty and raw display.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Success", + "status": "OK", + "code": 200, + "headers": [], + "body": '{"ok":true}', + "preview_language": "json", + "original_request": None, + "created_at": "2026-03-12 10:00", + "body_size": 11, + } + ] + ) + + assert "\n" in panel._body_edit.toPlainText() + panel._body_view_combo.setCurrentText("Raw") + assert panel._body_edit.toPlainText() == '{"ok":true}' + + def test_empty_examples_state(self, qapp: QApplication, qtbot) -> None: + """Persisted request with no examples shows the empty examples state.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses([]) + assert "No saved responses" in panel._state_label.text() + + def test_legacy_dict_headers_do_not_crash(self, qapp: QApplication, qtbot) -> None: + """Legacy saved responses with dict-shaped headers still render safely.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + cast( + SavedResponseDict, + { + "id": 11, + "request_id": 1, + "name": "Legacy", + "status": "OK", + "code": 200, + "headers": {"Content-Type": "application/json"}, + "body": "ok", + "preview_language": "json", + "original_request": { + "method": "GET", + "url": "https://example.com", + "headers": {"Accept": "application/json"}, + }, + "created_at": "2026-03-12 10:00", + "body_size": 2, + }, + ) + ] + ) + assert "Content-Type: application/json" in panel._headers_edit.toPlainText() + assert '"Accept": "application/json"' in panel._snapshot_edit.toPlainText() + + def test_snapshot_view_can_switch_between_pretty_and_raw( + self, qapp: QApplication, qtbot + ) -> None: + """Saved request snapshot can switch between pretty and compact JSON.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 12, + "request_id": 1, + "name": "Snapshot", + "status": "OK", + "code": 200, + "headers": [], + "body": "ok", + "preview_language": "text", + "original_request": {"method": "GET", "url": "https://example.com"}, + "created_at": "2026-03-12 10:00", + "body_size": 2, + } + ] + ) + + assert "\n" in panel._snapshot_edit.toPlainText() + panel._snapshot_view_combo.setCurrentText("Raw") + assert panel._snapshot_edit.toPlainText() == '{"method":"GET","url":"https://example.com"}' + + def test_view_modes_persist_across_saved_response_selection( + self, qapp: QApplication, qtbot + ) -> None: + """Body and snapshot view modes stay on the user's last chosen mode.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 12, + "request_id": 1, + "name": "First", + "status": "OK", + "code": 200, + "headers": [], + "body": '{"first":true}', + "preview_language": "json", + "original_request": {"method": "GET", "url": "https://example.com/a"}, + "created_at": "2026-03-12 10:00", + "body_size": 14, + }, + { + "id": 13, + "request_id": 1, + "name": "Second", + "status": "OK", + "code": 200, + "headers": [], + "body": '{"second":true}', + "preview_language": "json", + "original_request": {"method": "GET", "url": "https://example.com/b"}, + "created_at": "2026-03-12 10:01", + "body_size": 15, + }, + ] + ) + + panel._body_view_combo.setCurrentText("Raw") + panel._snapshot_view_combo.setCurrentText("Raw") + + panel.select_response(13) + + assert panel._body_view_combo.currentText() == "Raw" + assert panel._snapshot_view_combo.currentText() == "Raw" + assert panel._body_edit.toPlainText() == '{"second":true}' + assert ( + panel._snapshot_edit.toPlainText() == '{"method":"GET","url":"https://example.com/b"}' + ) + + def test_save_current_signal(self, qapp: QApplication, qtbot) -> None: + """Save Current button emits its signal when enabled.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_live_response_available(True) + with qtbot.waitSignal(panel.save_current_requested, timeout=1000): + panel._save_current_btn.click() + + def test_status_badge_shows_code_and_colour(self, qapp: QApplication, qtbot) -> None: + """Status badge displays the HTTP code with a coloured background.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Success", + "status": "OK", + "code": 200, + "headers": [], + "body": "ok", + "preview_language": "text", + "original_request": None, + "created_at": "2026-03-12 10:00", + "body_size": 2, + } + ] + ) + assert panel._status_badge.text() == "200" + assert "background:" in panel._status_badge.styleSheet() + + def test_delete_button_emits_signal(self, qapp: QApplication, qtbot) -> None: + """Delete icon button emits delete_requested for the current item.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Success", + "status": "OK", + "code": 200, + "headers": [], + "body": "ok", + "preview_language": "text", + "original_request": None, + "created_at": "2026-03-12 10:00", + "body_size": 2, + } + ] + ) + with qtbot.waitSignal(panel.delete_requested, timeout=1000) as blocker: + panel._delete_btn.click() + assert blocker.args == [10] + + def test_rename_and_duplicate_buttons_emit_signals(self, qapp: QApplication, qtbot) -> None: + """Rename and Duplicate icon buttons emit their respective signals.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Item", + "status": "OK", + "code": 200, + "headers": [], + "body": "ok", + "preview_language": "text", + "original_request": None, + "created_at": "2026-03-12 10:00", + "body_size": 2, + } + ] + ) + with qtbot.waitSignal(panel.rename_requested, timeout=1000) as blocker: + panel._rename_btn.click() + assert blocker.args == [10] + with qtbot.waitSignal(panel.duplicate_requested, timeout=1000) as blocker: + panel._duplicate_btn.click() + assert blocker.args == [10] + + def test_copy_body_button_copies_to_clipboard(self, qapp: QApplication, qtbot) -> None: + """Body copy button puts editor text onto the system clipboard.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Item", + "status": "OK", + "code": 200, + "headers": [{"key": "X", "value": "Y"}], + "body": '{"a":1}', + "preview_language": "json", + "original_request": {"method": "GET", "url": "https://x.com"}, + "created_at": "2026-03-12 10:00", + "body_size": 7, + } + ] + ) + panel._body_copy_btn.click() + clipboard = QApplication.clipboard() + assert clipboard is not None + assert "a" in clipboard.text() + + def test_empty_body_shows_empty_label(self, qapp: QApplication, qtbot) -> None: + """Empty body hides the editor and shows the empty-state label.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Empty", + "status": "No Content", + "code": 204, + "headers": [], + "body": "", + "preview_language": "text", + "original_request": None, + "created_at": "2026-03-12 10:00", + "body_size": 0, + } + ] + ) + assert not panel._body_empty_label.isHidden() + assert panel._body_edit.isHidden() + + def test_delegate_data_roles_on_list_items(self, qapp: QApplication, qtbot) -> None: + """List items carry custom data roles for the delegate to paint.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Check", + "status": "OK", + "code": 201, + "headers": [], + "body": "ok", + "preview_language": "text", + "original_request": None, + "created_at": "2026-03-12 10:00", + "body_size": 2, + } + ] + ) + item = panel._list_widget.item(0) + assert item.data(ROLE_RESPONSE_CODE) == 201 + assert item.data(ROLE_RESPONSE_NAME) == "Check" + assert isinstance(item.data(ROLE_RESPONSE_META), str) + + def test_enriched_detail_metadata(self, qapp: QApplication, qtbot) -> None: + """Detail metadata shows status, date, language, and body size.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Rich", + "status": "OK", + "code": 200, + "headers": [], + "body": "ok", + "preview_language": "json", + "original_request": None, + "created_at": "2026-03-12 10:00", + "body_size": 1024, + } + ] + ) + meta = panel._detail_meta.text() + assert "OK" in meta + assert "JSON" in meta + assert "1.0" in meta # 1024 bytes → "1.0 KB" + + def test_body_language_detected_from_json_body(self, qapp: QApplication, qtbot) -> None: + """When preview_language is None, JSON body gets syntax detected.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Auto", + "status": "Bad Request", + "code": 400, + "headers": [], + "body": '{"error": "bad"}', + "preview_language": None, + "original_request": None, + "created_at": "2026-03-12 10:00", + "body_size": 16, + } + ] + ) + assert panel._body_language == "json" + + +class TestDetectBodyLanguage: + """Unit tests for the detect_body_language helper.""" + + def test_json_object(self) -> None: + """Detect JSON object body.""" + assert detect_body_language('{"key": "value"}') == "json" + + def test_json_array(self) -> None: + """Detect JSON array body.""" + assert detect_body_language("[1, 2, 3]") == "json" + + def test_invalid_json_starting_with_brace(self) -> None: + """Brace-prefixed non-JSON returns None.""" + assert detect_body_language("{not json at all") is None + + def test_xml_body(self) -> None: + """Detect XML body.""" + assert detect_body_language("") == "xml" + + def test_html_doctype(self) -> None: + """Detect HTML with DOCTYPE.""" + assert detect_body_language("") == "html" + + def test_html_tag(self) -> None: + """Detect HTML starting with ") == "html" + + def test_plain_text(self) -> None: + """Plain text returns None.""" + assert detect_body_language("hello world") is None + + def test_empty_body(self) -> None: + """Empty string returns None.""" + assert detect_body_language("") is None + + def test_whitespace_json(self) -> None: + """Whitespace-padded JSON is still detected.""" + assert detect_body_language(' \n {"ok": true} ') == "json" diff --git a/tests/ui/sidebar/test_sidebar.py b/tests/ui/sidebar/test_sidebar.py index a515814..3a4a608 100644 --- a/tests/ui/sidebar/test_sidebar.py +++ b/tests/ui/sidebar/test_sidebar.py @@ -6,6 +6,7 @@ from PySide6.QtWidgets import QApplication +from ui.sidebar.saved_responses.panel import SavedResponsesPanel from ui.sidebar.sidebar_widget import RightSidebar from ui.sidebar.snippet_panel import SnippetPanel from ui.sidebar.variables_panel import VariablesPanel @@ -21,18 +22,20 @@ def test_construction(self, qapp: QApplication, qtbot) -> None: assert sidebar.objectName() == "sidebarRail" def test_panels_exist(self, qapp: QApplication, qtbot) -> None: - """Sidebar exposes variables_panel and snippet_panel.""" + """Sidebar exposes variables, snippet, and saved-responses panels.""" sidebar = RightSidebar() qtbot.addWidget(sidebar) assert isinstance(sidebar.variables_panel, VariablesPanel) assert isinstance(sidebar.snippet_panel, SnippetPanel) + assert isinstance(sidebar.saved_responses_panel, SavedResponsesPanel) def test_rail_buttons_exist(self, qapp: QApplication, qtbot) -> None: - """Sidebar has rail buttons for variables and code snippet.""" + """Sidebar has rail buttons for variables, snippet, and saved responses.""" sidebar = RightSidebar() qtbot.addWidget(sidebar) assert sidebar._var_btn is not None assert sidebar._snippet_btn is not None + assert sidebar._saved_btn is not None def test_buttons_start_disabled(self, qapp: QApplication, qtbot) -> None: """Rail buttons are disabled until a tab context is set.""" @@ -40,6 +43,7 @@ def test_buttons_start_disabled(self, qapp: QApplication, qtbot) -> None: qtbot.addWidget(sidebar) assert not sidebar._var_btn.isEnabled() assert sidebar._snippet_btn.isHidden() + assert sidebar._saved_btn.isHidden() def test_panel_starts_closed(self, qapp: QApplication, qtbot) -> None: """No panel is open on construction.""" @@ -70,6 +74,22 @@ def test_open_panel_snippet(self, qapp: QApplication, qtbot) -> None: assert not sidebar._var_btn.isChecked() assert sidebar._snippet_btn.isChecked() + def test_open_panel_saved_responses(self, qapp: QApplication, qtbot) -> None: + """open_panel('saved_responses') opens the saved responses panel.""" + sidebar = RightSidebar() + qtbot.addWidget(sidebar) + sidebar.show_request_panels({}, method="GET", url="") + sidebar.set_saved_response_context( + request_id=1, + request_name="Search", + items=[], + can_save_current=False, + is_persisted_request=True, + ) + sidebar.open_panel("saved_responses") + assert sidebar.active_panel == "saved_responses" + assert sidebar._saved_btn.isChecked() + def test_toggle_panel_closes_active(self, qapp: QApplication, qtbot) -> None: """Clicking the active panel's icon closes the panel.""" sidebar = RightSidebar() @@ -99,7 +119,7 @@ def test_show_request_panels_enables_both( qapp: QApplication, qtbot, ) -> None: - """show_request_panels enables both rail icons.""" + """show_request_panels enables all request-scoped rail icons.""" sidebar = RightSidebar() qtbot.addWidget(sidebar) variables: dict[str, Any] = { @@ -113,6 +133,8 @@ def test_show_request_panels_enables_both( assert sidebar._var_btn.isEnabled() assert not sidebar._snippet_btn.isHidden() assert sidebar._snippet_btn.isEnabled() + assert not sidebar._saved_btn.isHidden() + assert sidebar._saved_btn.isEnabled() def test_show_folder_panels_disables_snippet( self, @@ -128,6 +150,7 @@ def test_show_folder_panels_disables_snippet( sidebar.show_folder_panels(variables) assert sidebar._var_btn.isEnabled() assert sidebar._snippet_btn.isHidden() + assert sidebar._saved_btn.isHidden() def test_show_folder_closes_snippet_panel( self, @@ -153,6 +176,7 @@ def test_clear_disables_all(self, qapp: QApplication, qtbot) -> None: sidebar.clear() assert not sidebar._var_btn.isEnabled() assert sidebar._snippet_btn.isHidden() + assert sidebar._saved_btn.isHidden() assert sidebar.active_panel is None def test_close_button_closes_panel(self, qapp: QApplication, qtbot) -> None: diff --git a/tests/unit/services/test_service.py b/tests/unit/services/test_service.py index 8f2d240..e5ecaeb 100644 --- a/tests/unit/services/test_service.py +++ b/tests/unit/services/test_service.py @@ -4,6 +4,7 @@ import pytest +from database.models.collections.collection_repository import save_response as repo_save_response from services.collection_service import CollectionService @@ -170,12 +171,83 @@ def test_save_and_fetch(self) -> None: svc = CollectionService() coll = svc.create_collection("C") req = svc.create_request(coll.id, "GET", "http://x", "R") - sr_id = svc.save_response(req.id, "Example", "200 OK", 200, "H: V", "body") + sr_id = svc.save_response( + req.id, + "Example", + "OK", + 200, + [{"key": "Content-Type", "value": "application/json"}], + '{"ok": true}', + preview_language="json", + original_request={"method": "GET", "url": "http://x"}, + ) assert sr_id > 0 responses = svc.get_saved_responses(req.id) assert len(responses) == 1 assert responses[0]["name"] == "Example" - assert responses[0]["body"] == "body" + assert responses[0]["body"] == '{"ok": true}' + assert responses[0]["preview_language"] == "json" + assert responses[0]["original_request"] == {"method": "GET", "url": "http://x"} + assert responses[0]["body_size"] > 0 + + def test_get_single_saved_response(self) -> None: + """A saved response can be fetched back by ID.""" + svc = CollectionService() + coll = svc.create_collection("C") + req = svc.create_request(coll.id, "GET", "http://x", "R") + sr_id = svc.save_response(req.id, "Example", "OK", 200, [], "body") + response = svc.get_saved_response(sr_id) + assert response is not None + assert response["id"] == sr_id + assert response["request_id"] == req.id + + def test_rename_duplicate_and_delete(self) -> None: + """Saved responses support rename, duplicate, and delete operations.""" + svc = CollectionService() + coll = svc.create_collection("C") + req = svc.create_request(coll.id, "GET", "http://x", "R") + sr_id = svc.save_response(req.id, "Example", "OK", 200, [], "body") + + svc.rename_saved_response(sr_id, "Renamed") + renamed = svc.get_saved_response(sr_id) + assert renamed is not None + assert renamed["name"] == "Renamed" + + dup_id = svc.duplicate_saved_response(sr_id) + duplicate = svc.get_saved_response(dup_id) + assert duplicate is not None + assert duplicate["name"] == "Renamed (copy)" + + svc.delete_saved_response(sr_id) + assert svc.get_saved_response(sr_id) is None + + def test_get_saved_response_normalizes_legacy_dict_headers(self) -> None: + """Legacy saved responses with dict-shaped headers are normalized for the UI.""" + svc = CollectionService() + coll = svc.create_collection("C") + req = svc.create_request(coll.id, "GET", "http://x", "R") + sr_id = repo_save_response( + req.id, + "Legacy", + "OK", + 200, + {"Content-Type": "application/json"}, + "body", + original_request={ + "method": "GET", + "url": "http://x", + "headers": {"Accept": "application/json"}, + }, + ) + + response = svc.get_saved_response(sr_id) + + assert response is not None + assert response["headers"] == [{"key": "Content-Type", "value": "application/json"}] + assert response["original_request"] is not None + assert response["original_request"]["headers"] == [ + {"key": "Accept", "value": "application/json"} + ] class TestCollectionUpdates: @@ -263,3 +335,4 @@ def test_recent_requests_empty(self) -> None: svc = CollectionService() coll = svc.create_collection("Empty") assert svc.get_recent_requests(coll.id) == [] + assert svc.get_recent_requests(coll.id) == []