Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions .github/instructions/architecture.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions .github/instructions/pyside6.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 5 additions & 4 deletions .github/instructions/testing.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion .github/skills/service-repository-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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 |

Expand Down Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions .github/skills/signal-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 35 additions & 12 deletions src/database/models/collections/collection_query_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
52 changes: 52 additions & 0 deletions src/database/models/collections/collection_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
Loading
Loading