From 9a0419c60a94ca9cf55db29199f680cae50b5a35 Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Sun, 31 May 2026 14:24:07 +0300 Subject: [PATCH 1/7] refactor: Remove HistoryPanel and related references from the codebase - Deleted the HistoryPanel class and its associated tests, as well as all references to it in the UI and documentation. - Updated the ConsolePanel to serve as the sole bottom panel, simplifying the UI structure. - Adjusted documentation and code comments to reflect the removal of the history functionality. --- .agents/skills/signal-flow/SKILL.md | 10 +- AGENTS.md | 6 +- docs/README.md | 2 +- docs/api-reference/signals.md | 8 - docs/architecture/data-flow.md | 1 - docs/architecture/directory-structure.md | 2 - docs/architecture/ui-layer.md | 4 +- docs/getting-started/overview.md | 4 +- docs/ui-reference/main-window.md | 2 +- docs/ui-reference/panels.md | 32 +--- src/ui/AGENTS.md | 1 - src/ui/main_window/send_pipeline.py | 11 +- .../main_window/send_pipeline_postresponse.py | 7 - src/ui/main_window/window.py | 14 +- src/ui/panels/history_panel.py | 167 ------------------ tests/AGENTS.md | 3 +- tests/ui/panels/test_history_panel.py | 53 ------ 17 files changed, 15 insertions(+), 312 deletions(-) delete mode 100644 src/ui/panels/history_panel.py delete mode 100644 tests/ui/panels/test_history_panel.py diff --git a/.agents/skills/signal-flow/SKILL.md b/.agents/skills/signal-flow/SKILL.md index 0032a3e..5135328 100644 --- a/.agents/skills/signal-flow/SKILL.md +++ b/.agents/skills/signal-flow/SKILL.md @@ -276,13 +276,6 @@ FolderEditor.collection_changed(dict) → MainWindow handler → CollectionService.update_collection(...) ``` -### History panel flow - -``` -HistoryPanel.entry_clicked(method, url) - → (wired to open or populate editor) -``` - ### Toggle actions flow ``` @@ -293,7 +286,7 @@ MainWindow._toggle_sidebar_action.triggered → _toggle_sidebar (collapse/expand left flyout; rail stays visible; stacked page unchanged) MainWindow._toggle_bottom_action.triggered - → _toggle_bottom_panel (show/hide console/history) + → _toggle_bottom_panel (show/hide console) MainWindow._toggle_layout_action.triggered → _toggle_layout_orientation (horizontal ↔ vertical) @@ -579,7 +572,6 @@ All other signals in the flow diagrams above are fully wired. | `CodeEditorWidget` | `validation_changed` | `Signal(list)` | | `CodeEditorWidget` | `run_single_test_requested` | `Signal(str)` — per-`pm.test` gutter Run | | `CodeEditorWidget` | `debug_single_test_requested` | `Signal(str)` — per-`pm.test` gutter Debug | -| `HistoryPanel` | `entry_clicked` | `Signal(str, str)` | ## MainWindow signal wiring summary diff --git a/AGENTS.md b/AGENTS.md index 0078491..a3ba48e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -403,8 +403,7 @@ src/ │ ├── environment_selector.py │ └── environment_sidebar_panel.py ├── panels/ # Bottom / side panels - │ ├── console_panel.py - │ └── history_panel.py + │ └── console_panel.py └── request/ # Request/response editing ├── folder_editor/ # Folder/collection detail editor sub-package │ ├── editor_widget.py # FolderEditorWidget — main editor class @@ -570,8 +569,7 @@ tests/ │ ├── test_environment_selector.py │ └── test_environment_sidebar_panel.py ├── panels/ # Panel tests - │ ├── test_console_panel.py - │ └── test_history_panel.py + │ └── test_console_panel.py └── request/ # Request/response editing tests ├── conftest.py # make_request_dict fixture factory ├── test_folder_editor.py diff --git a/docs/README.md b/docs/README.md index 1df8652..7704b69 100644 --- a/docs/README.md +++ b/docs/README.md @@ -75,7 +75,7 @@ | [Navigation](ui-reference/navigation.md) | Tab manager, breadcrumb bar, wrapped tab deck | | [Sidebar](ui-reference/sidebar.md) | Right sidebar — variables, snippets, saved responses | | [Dialogs](ui-reference/dialogs.md) | Import, Save, Settings, Collection Runner | -| [Panels](ui-reference/panels.md) | Console and History panels | +| [Panels](ui-reference/panels.md) | Console panel | | [Shared Widgets](ui-reference/widgets.md) | Code editor, key-value table, popups, variable widgets | | [Styling](ui-reference/styling.md) | Theme manager, palettes, global QSS, icon system | diff --git a/docs/api-reference/signals.md b/docs/api-reference/signals.md index 95fe26b..c86b9cb 100644 --- a/docs/api-reference/signals.md +++ b/docs/api-reference/signals.md @@ -324,14 +324,6 @@ Source: `ui/panels/console_panel.py` |--------|------------|-------------| | `log_message` | `str` | New log message received | -### HistoryPanel - -Source: `ui/panels/history_panel.py` - -| Signal | Parameters | Description | -|--------|------------|-------------| -| `entry_clicked` | `str, str` | History entry clicked (method, url) | - ## Signal Patterns **Worker threads** use a consistent `finished`/`error` pair. The diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md index 63b7278..e0a3549 100644 --- a/docs/architecture/data-flow.md +++ b/docs/architecture/data-flow.md @@ -27,7 +27,6 @@ User clicks Send 7. MainWindow._on_response_received(response_dict) --> ResponseViewer.display_response(response_dict) --> Console logs the request/response - --> History panel records the entry ``` ## 2. Open Request from Collection Tree diff --git a/docs/architecture/directory-structure.md b/docs/architecture/directory-structure.md index d79d255..9702b13 100644 --- a/docs/architecture/directory-structure.md +++ b/docs/architecture/directory-structure.md @@ -120,7 +120,6 @@ src/ | +-- environment_sidebar_panel.py EnvironmentSidebarPanel — left column global env picker +-- panels/ Bottom panels | +-- console_panel.py Console output panel - | +-- history_panel.py Request history panel +-- request/ Request/response editing +-- folder_editor/ Folder/collection detail editor sub-package | +-- editor_widget.py FolderEditorWidget -- main editor class @@ -228,7 +227,6 @@ tests/ | +-- test_environment_sidebar_panel.py EnvironmentSidebarPanel tests +-- panels/ | +-- test_console_panel.py ConsolePanel tests - | +-- test_history_panel.py HistoryPanel tests +-- request/ +-- conftest.py make_request_dict fixture factory +-- test_folder_editor.py FolderEditorWidget tests diff --git a/docs/architecture/ui-layer.md b/docs/architecture/ui-layer.md index ccc8064..f8d1b5d 100644 --- a/docs/architecture/ui-layer.md +++ b/docs/architecture/ui-layer.md @@ -60,9 +60,7 @@ MainWindow (QMainWindow) | +-- SnippetPanel (code snippet generator) | +-- SavedResponsesPanel (saved examples) +-- RightSidebar (right activity rail) - +-- QTabWidget (bottom panels) - +-- ConsolePanel (HTTP traffic log) - +-- HistoryPanel (request history) + +-- ConsolePanel (collapsible bottom log) ``` ## Background Workers diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index 2082a44..a634d82 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -47,8 +47,8 @@ external service required. - **Collection runner** — execute all requests in a folder tab (**Runs → New run**) with per-request test results, script flow control (`setNextRequest`, `skipRequest`), and data-driven iterations. -- **Console and history panels** — log panel for HTTP traffic, history of - sent requests. Script `console.log()` / `print()` output appears here. +- **Console panel** — application log and script `console.log()` / + `print()` output (View → Toggle Console, `Ctrl+J`). - **Theming** — dark and light themes with Fusion and native Qt styles. - **Code editor** — syntax highlighting, code folding, line numbers, bracket matching, search and replace. diff --git a/docs/ui-reference/main-window.md b/docs/ui-reference/main-window.md index e598588..fd34ac9 100644 --- a/docs/ui-reference/main-window.md +++ b/docs/ui-reference/main-window.md @@ -34,7 +34,7 @@ QMainWindow | | | (status | time | size | network) | | | | | | (Body | Headers) | | | +------------------------------------------------------------------------+ -| Console / History (collapsible bottom panel) | +| Console (collapsible bottom panel) | +------------------------------------------------------------------------+ ``` diff --git a/docs/ui-reference/panels.md b/docs/ui-reference/panels.md index 650cc79..144bcfe 100644 --- a/docs/ui-reference/panels.md +++ b/docs/ui-reference/panels.md @@ -1,6 +1,6 @@ # Panels -Collapsible bottom panels for console output and request history. +Collapsible bottom panel for console output. Source: `src/ui/panels/` @@ -26,33 +26,3 @@ the UI update happens on the main thread. ### Limits Maximum 2000 lines in memory (oldest lines removed first). - -## HistoryPanel - -Recent HTTP request history. - -### UI - -Title bar with "History" label and a Clear button. Scrollable list -of `_HistoryEntry` items. - -### Entry Format - -``` -+------+-----------------------------+-----+--------+ -| POST | https://api.example.com/v1 | 200 | 245 ms | -+------+-----------------------------+-----+--------+ -``` - -Each entry shows method (coloured badge), URL, status code, and -elapsed time. - -### Signals - -| Signal | Parameters | Description | -|--------|------------|-------------| -| `entry_clicked` | `str, str` | Entry clicked (method, url) | - -### Limits - -Maximum 50 entries. diff --git a/src/ui/AGENTS.md b/src/ui/AGENTS.md index c0145e2..71d05d2 100644 --- a/src/ui/AGENTS.md +++ b/src/ui/AGENTS.md @@ -357,7 +357,6 @@ varies at runtime and cannot be expressed with objectName selectors: - Method badge background-color (varies by HTTP method) - Status label colour (varies by HTTP status code) -- History row method colour - Breadcrumb per-segment colour - Spinner animation colours - Drop-zone active hover overlay diff --git a/src/ui/main_window/send_pipeline.py b/src/ui/main_window/send_pipeline.py index 52e063b..6d77821 100644 --- a/src/ui/main_window/send_pipeline.py +++ b/src/ui/main_window/send_pipeline.py @@ -15,7 +15,6 @@ if TYPE_CHECKING: from services.scripting.debug import DebugProtocol from ui.environments.environment_sidebar_panel import EnvironmentSidebarPanel - from ui.panels.history_panel import HistoryPanel from ui.request.http_worker import HttpSendWorker from ui.request.navigation.request_tab_bar import RequestTabBar from ui.request.navigation.tab_manager import TabContext @@ -87,8 +86,7 @@ class _SendPipelineMixin: Expects the host class to provide ``_tabs``, ``_tab_bar``, ``_send_thread``, ``_send_worker``, ``request_widget``, - ``response_widget``, ``_env_selector`` (``EnvironmentSidebarPanel``), - and ``_history_panel``. + ``response_widget``, and ``_env_selector`` (``EnvironmentSidebarPanel``). """ # -- Host-class interface (declared for mypy) ----------------------- @@ -96,7 +94,6 @@ class _SendPipelineMixin: _send_worker: HttpSendWorker | None _tab_bar: RequestTabBar _env_selector: EnvironmentSidebarPanel - _history_panel: HistoryPanel _right_sidebar: RightSidebar request_widget: RequestEditorWidget response_widget: ResponseViewerWidget @@ -304,12 +301,6 @@ def _on_send_error(self, message: str) -> None: ctx.cleanup_thread() else: self._cleanup_send_thread() - # Add error entry to history panel - editor = ctx.require_editor() if ctx is not None else self.request_widget - self._history_panel.add_entry( - editor._method_combo.currentText(), - editor._url_input.text(), - ) self._refresh_sidebar() if inline_test is not None: panel = inline_test.get("panel") diff --git a/src/ui/main_window/send_pipeline_postresponse.py b/src/ui/main_window/send_pipeline_postresponse.py index 5488f27..67d7a80 100644 --- a/src/ui/main_window/send_pipeline_postresponse.py +++ b/src/ui/main_window/send_pipeline_postresponse.py @@ -96,13 +96,6 @@ def on_send_finished(window: Any, data: dict) -> None: ctx.cleanup_thread() else: window._cleanup_send_thread() - editor = ctx.require_editor() if ctx is not None else window.request_widget - window._history_panel.add_entry( - editor._method_combo.currentText(), - editor._url_input.text(), - data.get("status_code"), - data.get("elapsed_ms", 0), - ) window._refresh_sidebar() if inline_test is not None: diff --git a/src/ui/main_window/window.py b/src/ui/main_window/window.py index 83f75a3..51352b1 100644 --- a/src/ui/main_window/window.py +++ b/src/ui/main_window/window.py @@ -22,7 +22,6 @@ QSplitter, QStackedWidget, QStatusBar, - QTabWidget, QVBoxLayout, QWidget, ) @@ -37,7 +36,6 @@ from ui.main_window.tab_nav import _TabNavHistoryMixin from ui.main_window.variable_controller import _VariableControllerMixin from ui.panels.console_panel import ConsolePanel -from ui.panels.history_panel import HistoryPanel from ui.request.navigation.breadcrumb_bar import BreadcrumbBar from ui.request.navigation.request_tab_bar import RequestTabBar from ui.request.navigation.tab_manager import TabContext @@ -326,7 +324,7 @@ def _create_menus(self) -> None: self._toggle_sidebar_action.triggered.connect(self._toggle_sidebar) view_menu.addAction(self._toggle_sidebar_action) - self._toggle_bottom_action = QAction("Toggle &Bottom Panel", self) + self._toggle_bottom_action = QAction("Toggle &Console", self) self._toggle_bottom_action.setShortcut(QKeySequence("Ctrl+J")) self._toggle_bottom_action.triggered.connect(self._toggle_bottom_panel) view_menu.addAction(self._toggle_bottom_action) @@ -559,13 +557,9 @@ def _setup_ui(self) -> None: self._response_area = self._build_response_area() self._right_splitter.addWidget(self._response_area) - # --- Bottom panel (History + Console) --- - self._bottom_panel = QTabWidget() - self._bottom_panel.setTabPosition(QTabWidget.TabPosition.South) - self._history_panel = HistoryPanel() + # --- Bottom panel (Console) --- self._console_panel = ConsolePanel() - self._bottom_panel.addTab(self._history_panel, "History") - self._bottom_panel.addTab(self._console_panel, "Console") + self._bottom_panel = self._console_panel # alias for toggle/tests self._bottom_panel.hide() self._right_splitter.addWidget(self._bottom_panel) @@ -665,7 +659,7 @@ def _toggle_sidebar(self) -> None: self._sync_sidebar_toggle_btn() def _toggle_bottom_panel(self) -> None: - """Show or hide the bottom panel (History / Console).""" + """Show or hide the bottom panel (Console).""" self._bottom_panel.setVisible(self._bottom_panel.isHidden()) def _toggle_layout_orientation(self) -> None: diff --git a/src/ui/panels/history_panel.py b/src/ui/panels/history_panel.py deleted file mode 100644 index 120e67d..0000000 --- a/src/ui/panels/history_panel.py +++ /dev/null @@ -1,167 +0,0 @@ -"""History panel showing recently sent HTTP requests. - -Tracks requests that have been sent during the current session and -displays them in a scrollable list with method, URL, status, and timing. -""" - -from __future__ import annotations - -from PySide6.QtCore import QEvent, QObject, Qt, Signal -from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout, QWidget - -from ui.styling.icons import phi -from ui.styling.theme import COLOR_BORDER, method_color - -# Maximum number of history entries to keep -_MAX_HISTORY_ENTRIES = 50 - - -class HistoryEntry: - """Data class for a single history entry.""" - - def __init__( - self, - method: str, - url: str, - status_code: int | None = None, - elapsed_ms: float = 0, - ) -> None: - """Store a history entry.""" - self.method = method - self.url = url - self.status_code = status_code - self.elapsed_ms = elapsed_ms - - -class HistoryPanel(QWidget): - """Panel showing recently sent HTTP requests. - - Signals: - entry_clicked(str, str): Emitted with ``(method, url)`` when a - history entry is clicked. - """ - - entry_clicked = Signal(str, str) - - def __init__(self, parent: QWidget | None = None) -> None: - """Initialise the history panel.""" - super().__init__(parent) - root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) - - # Header - header = QHBoxLayout() - title = QLabel("History") - title.setObjectName("panelTitle") - header.addWidget(title) - header.addStretch() - clear_btn = QPushButton("Clear") - clear_btn.setIcon(phi("eraser")) - clear_btn.setObjectName("linkButton") - clear_btn.setCursor(Qt.CursorShape.PointingHandCursor) - clear_btn.clicked.connect(self.clear) - header.addWidget(clear_btn) - root.addLayout(header) - - # Scroll area for entries - self._scroll = QScrollArea() - self._scroll.setWidgetResizable(True) - self._scroll.setStyleSheet(f"border-top: 1px solid {COLOR_BORDER};") - self._scroll_content = QWidget() - self._scroll_layout = QVBoxLayout(self._scroll_content) - self._scroll_layout.setContentsMargins(0, 0, 0, 0) - self._scroll_layout.setSpacing(0) - self._scroll_layout.addStretch() - self._scroll.setWidget(self._scroll_content) - root.addWidget(self._scroll, 1) - - self._entries: list[HistoryEntry] = [] - - def add_entry( - self, - method: str, - url: str, - status_code: int | None = None, - elapsed_ms: float = 0, - ) -> None: - """Add a new history entry at the top.""" - entry = HistoryEntry(method, url, status_code, elapsed_ms) - self._entries.insert(0, entry) - if len(self._entries) > _MAX_HISTORY_ENTRIES: - self._entries = self._entries[:_MAX_HISTORY_ENTRIES] - self._rebuild_list() - - def clear(self) -> None: - """Remove all history entries.""" - self._entries.clear() - self._rebuild_list() - - @property - def entries(self) -> list[HistoryEntry]: - """Return the current list of history entries.""" - return list(self._entries) - - def _rebuild_list(self) -> None: - """Rebuild the displayed list of history entries.""" - # Remove old widgets (keep the trailing stretch) - while self._scroll_layout.count() > 1: - item = self._scroll_layout.takeAt(0) - if item is not None: - w = item.widget() - if w is not None: - w.deleteLater() - - for entry in self._entries: - row = self._make_entry_widget(entry) - idx = self._scroll_layout.count() - 1 # insert before stretch - self._scroll_layout.insertWidget(idx, row) - - def _make_entry_widget(self, entry: HistoryEntry) -> QWidget: - """Create a widget row for a single history entry.""" - row = QWidget() - layout = QHBoxLayout(row) - layout.setContentsMargins(8, 6, 8, 6) - layout.setSpacing(8) - - method_label = QLabel(entry.method) - color = method_color(entry.method) - method_label.setStyleSheet( - f"color: {color}; font-weight: bold; font-size: 11px; font-family: monospace;" - ) - method_label.setFixedWidth(40) - layout.addWidget(method_label) - - url_label = QLabel(entry.url) - url_label.setWordWrap(False) - layout.addWidget(url_label, 1) - - if entry.status_code is not None: - status = QLabel(str(entry.status_code)) - status.setObjectName("mutedLabel") - layout.addWidget(status) - - if entry.elapsed_ms: - time_label = QLabel(f"{entry.elapsed_ms:.0f}ms") - time_label.setObjectName("mutedLabel") - layout.addWidget(time_label) - - row.setStyleSheet(f"QWidget {{ border-bottom: 1px solid {COLOR_BORDER}; }}") - row.setCursor(Qt.CursorShape.PointingHandCursor) - row.setProperty("entry_method", entry.method) - row.setProperty("entry_url", entry.url) - row.installEventFilter(self) - return row - - def eventFilter(self, obj: QObject, event: QEvent) -> bool: - """Emit ``entry_clicked`` when a history row is pressed.""" - if event.type() == QEvent.Type.MouseButtonPress: - method = obj.property("entry_method") - url = obj.property("entry_url") - if method is not None and url is not None: - self.entry_clicked.emit(str(method), str(url)) - return True - return super().eventFilter(obj, event) - return super().eventFilter(obj, event) - return super().eventFilter(obj, event) - return super().eventFilter(obj, event) diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 895aabe..c7a7eb4 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -283,8 +283,7 @@ tests/ │ ├── test_environment_selector.py │ └── test_environment_sidebar_panel.py ├── panels/ # Panel tests - │ ├── test_console_panel.py - │ └── test_history_panel.py + │ └── test_console_panel.py └── request/ # Request/response editing tests ├── test_folder_editor.py ├── test_folder_editor_scripts.py diff --git a/tests/ui/panels/test_history_panel.py b/tests/ui/panels/test_history_panel.py deleted file mode 100644 index 542b240..0000000 --- a/tests/ui/panels/test_history_panel.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Tests for the HistoryPanel widget.""" - -from __future__ import annotations - -from PySide6.QtWidgets import QApplication - -from ui.panels.history_panel import HistoryPanel - - -class TestHistoryPanel: - """Tests for the history panel widget.""" - - def test_construction(self, qapp: QApplication, qtbot) -> None: - """HistoryPanel can be created without errors.""" - panel = HistoryPanel() - qtbot.addWidget(panel) - assert len(panel.entries) == 0 - - def test_add_entry(self, qapp: QApplication, qtbot) -> None: - """Adding an entry increases the entry count.""" - panel = HistoryPanel() - qtbot.addWidget(panel) - panel.add_entry("GET", "http://example.com", status_code=200, elapsed_ms=42) - assert len(panel.entries) == 1 - assert panel.entries[0].method == "GET" - assert panel.entries[0].url == "http://example.com" - - def test_add_multiple_entries(self, qapp: QApplication, qtbot) -> None: - """Adding multiple entries keeps them in reverse order.""" - panel = HistoryPanel() - qtbot.addWidget(panel) - panel.add_entry("GET", "http://a.com") - panel.add_entry("POST", "http://b.com") - assert len(panel.entries) == 2 - # Most recent first - assert panel.entries[0].method == "POST" - assert panel.entries[1].method == "GET" - - def test_clear(self, qapp: QApplication, qtbot) -> None: - """Clearing the panel removes all entries.""" - panel = HistoryPanel() - qtbot.addWidget(panel) - panel.add_entry("GET", "http://example.com") - panel.clear() - assert len(panel.entries) == 0 - - def test_max_entries(self, qapp: QApplication, qtbot) -> None: - """History panel caps at 50 entries.""" - panel = HistoryPanel() - qtbot.addWidget(panel) - for i in range(55): - panel.add_entry("GET", f"http://example.com/{i}") - assert len(panel.entries) == 50 From b721509d2040b844b1b6cca9e768464507ca5b8d Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Mon, 1 Jun 2026 18:57:04 +0300 Subject: [PATCH 2/7] feat: Implement request history tracking and management - Introduced `RequestHistoryService` for persisting HTTP send history, including methods for recording sends, retrieving entries, and managing retention settings. - Added `RequestHistoryEntryModel` to the database schema for storing metadata related to each request, including response bodies and snapshots. - Implemented file-backed storage for request bodies and snapshots in `body_store.py`, ensuring efficient management of historical data. - Updated documentation to reflect new request history features and their integration into the UI, including the HistoryPanel for displaying recorded sends. - Enhanced the database initialization process to handle legacy paths and files, ensuring a smooth transition for existing users. --- .../service-repository-reference/SKILL.md | 36 + .agents/skills/signal-flow/SKILL.md | 33 + .claude/scheduled_tasks.lock | 1 - AGENTS.md | 46 +- docs/architecture/overview.md | 11 +- docs/ui-reference/sidebar.md | 48 +- scripts/close_stuck_qt_test_windows.py | 43 ++ scripts/profile_startup.py | 5 +- src/AGENTS.md | 14 +- src/database/AGENTS.md | 31 +- src/database/data_paths.py | 42 ++ src/database/database.py | 14 +- src/database/models/__init__.py | 2 + .../collections/collection_repository.py | 3 + .../models/request_history/__init__.py | 5 + .../models/request_history/body_store.py | 257 +++++++ .../model/request_history_entry_model.py | 43 ++ .../request_history_repository.py | 253 +++++++ src/main.py | 6 +- src/services/__init__.py | 8 + src/services/request_history_service.py | 345 +++++++++ src/ui/AGENTS.md | 8 + src/ui/dialogs/settings/__init__.py | 3 + src/ui/dialogs/settings/history_page.py | 141 ++++ src/ui/dialogs/settings_dialog.py | 32 + src/ui/main_window/draft_controller.py | 2 + src/ui/main_window/send_pipeline.py | 220 ++++-- .../send_pipeline_debug_session.py | 6 +- .../main_window/send_pipeline_postresponse.py | 104 ++- src/ui/main_window/session_restore.py | 169 +++++ src/ui/main_window/startup_workers.py | 21 + src/ui/main_window/tab_controller.py | 82 +-- src/ui/main_window/variable_controller.py | 5 + src/ui/main_window/window.py | 52 +- src/ui/request/navigation/tab_manager.py | 3 + .../scripts/script_editor_pane/pane.py | 5 +- .../request_editor/scripts/scripts_mixin.py | 7 + .../response_viewer/replay_indicator.py | 61 ++ .../request/response_viewer/viewer_widget.py | 17 + src/ui/sidebar/history/__init__.py | 7 + src/ui/sidebar/history/delegate.py | 159 +++++ src/ui/sidebar/history/helpers.py | 170 +++++ src/ui/sidebar/history/panel.py | 672 ++++++++++++++++++ src/ui/sidebar/history/panel_detail_tabs.py | 96 +++ src/ui/sidebar/history/search_filter.py | 7 + .../sidebar/saved_responses/search_filter.py | 4 + src/ui/sidebar/sidebar_widget.py | 96 ++- src/ui/styling/global_qss.py | 55 ++ src/ui/styling/history_settings_manager.py | 164 +++++ tests/AGENTS.md | 41 +- tests/conftest.py | 42 +- tests/qt_popup_cleanup.py | 49 +- tests/ui/conftest.py | 19 + tests/ui/dialogs/test_settings_dialog.py | 61 +- tests/ui/local_scripts/conftest.py | 40 ++ .../test_local_script_editor_widget.py | 61 +- .../request/test_response_replay_indicator.py | 78 ++ .../ui/sidebar/test_request_history_panel.py | 385 ++++++++++ .../test_right_sidebar_request_history.py | 61 ++ tests/ui/test_main_window_session.py | 41 +- tests/ui/test_main_window_tab_nav_history.py | 4 +- tests/unit/database/test_data_paths.py | 26 + .../test_request_history_body_store.py | 165 +++++ .../test_request_history_repository.py | 176 +++++ .../services/test_request_history_replay.py | 92 +++ .../services/test_request_history_service.py | 41 ++ .../test_request_history_snapshot_headers.py | 34 + 67 files changed, 4794 insertions(+), 236 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock create mode 100644 scripts/close_stuck_qt_test_windows.py create mode 100644 src/database/data_paths.py create mode 100644 src/database/models/request_history/__init__.py create mode 100644 src/database/models/request_history/body_store.py create mode 100644 src/database/models/request_history/model/request_history_entry_model.py create mode 100644 src/database/models/request_history/request_history_repository.py create mode 100644 src/services/request_history_service.py create mode 100644 src/ui/dialogs/settings/__init__.py create mode 100644 src/ui/dialogs/settings/history_page.py create mode 100644 src/ui/main_window/session_restore.py create mode 100644 src/ui/main_window/startup_workers.py create mode 100644 src/ui/request/response_viewer/replay_indicator.py create mode 100644 src/ui/sidebar/history/__init__.py create mode 100644 src/ui/sidebar/history/delegate.py create mode 100644 src/ui/sidebar/history/helpers.py create mode 100644 src/ui/sidebar/history/panel.py create mode 100644 src/ui/sidebar/history/panel_detail_tabs.py create mode 100644 src/ui/sidebar/history/search_filter.py create mode 100644 src/ui/styling/history_settings_manager.py create mode 100644 tests/ui/local_scripts/conftest.py create mode 100644 tests/ui/request/test_response_replay_indicator.py create mode 100644 tests/ui/sidebar/test_request_history_panel.py create mode 100644 tests/ui/sidebar/test_right_sidebar_request_history.py create mode 100644 tests/unit/database/test_data_paths.py create mode 100644 tests/unit/database/test_request_history_body_store.py create mode 100644 tests/unit/database/test_request_history_repository.py create mode 100644 tests/unit/services/test_request_history_replay.py create mode 100644 tests/unit/services/test_request_history_service.py create mode 100644 tests/unit/services/test_request_history_snapshot_headers.py diff --git a/.agents/skills/service-repository-reference/SKILL.md b/.agents/skills/service-repository-reference/SKILL.md index 93a6707..4d643cc 100644 --- a/.agents/skills/service-repository-reference/SKILL.md +++ b/.agents/skills/service-repository-reference/SKILL.md @@ -79,6 +79,24 @@ cross-layer data interchange. | `delete_run(run_id)` | `bool` | Delete a single run (True if found) | | `delete_runs_for_collection(collection_id)` | `int` | Delete all runs for a collection, return count | +### Request history repository (`request_history_repository.py`) + +Metadata in SQLite; bodies/snapshots via `body_store.py` under +`user_history_root()` (`postmark_user_data_dir()/history`). + +| Function | Returns | Purpose | +|----------|---------|---------| +| `insert_entry(...)` | `dict[str, Any]` | Insert row + write body/snapshot files; truncate body to `max_response_bytes` | +| `get_entry(entry_id)` | `dict \| None` | Row + loaded body/snapshot bytes | +| `list_entries_for_sidebar(search?, limit?)` | `list[dict]` | Newest-first global list (future left rail) | +| `list_for_request(request_id, search?, limit?)` | `list[dict]` | Newest-first rows for one persisted `request_id` | +| `prune_old_entries(retention_days, max_items_per_day, unlimited_per_day)` | `None` | Drop rows older than retention and over per-day cap | +| `nullify_request_id(request_id)` | `None` | Set `request_id` NULL when collection request deleted | +| `local_date(executed_at)` | `date` | Local calendar date for per-day caps | + +`body_store`: `write_body`, `read_body`, `write_request_snapshot`, +`read_request_snapshot`, `delete_entry_files`, `reconcile_orphans`. + ### Local script repository (`local_script_repository.py`) | Function | Returns | Purpose | @@ -212,6 +230,24 @@ history CRUD. | `delete_run(run_id)` | Delete a single run | | `delete_runs(collection_id)` | Delete all runs for a collection | +### RequestHistoryService (`services/request_history_service.py`) + +Module-level functions; class re-exports them as `@staticmethod` aliases. + +| Method | Purpose | +|--------|---------| +| `gather_send_identity(ctx, editor, data)` | Capture method/url/name at send start | +| `record_send(identity, response, original_request, settings)` | Persist send; prune per settings; return entry id | +| `list_for_sidebar(search?)` | List all entries (global; future left rail) | +| `list_for_request(request_id, search?)` | List sends for one saved request (right rail) | +| `get_entry(entry_id)` | Full row with file payloads | +| `entry_to_detail_snapshot(entry)` | Shape for future response viewer replay | + +TypedDicts: `SendIdentityDict`, `RequestHistoryEntryDict`. + +Settings: `HistorySettingsManager` (`history/retention_days`, `max_items_per_day`, +`unlimited_per_day`, `save_responses`, `max_response_bytes`). + ### LocalScriptService (`services/local_script_service.py`) All methods are `@staticmethod`. UI must use this module, not `database/`. diff --git a/.agents/skills/signal-flow/SKILL.md b/.agents/skills/signal-flow/SKILL.md index 5135328..dd31afb 100644 --- a/.agents/skills/signal-flow/SKILL.md +++ b/.agents/skills/signal-flow/SKILL.md @@ -267,6 +267,39 @@ ResponseViewerWidget.save_availability_changed(bool) → _TabControllerMixin lambda → MainWindow._refresh_sidebar() → RightSidebar.set_saved_response_context(...can_save_current=...) + +MainWindow._refresh_sidebar (request tab) + → RightSidebar.set_request_history_context(...) + → HistoryPanel.set_request_context(...) + refresh() + +on_send_finished → _record_request_history + → RequestHistoryService.record_send(...) + → HistoryPanel.refresh() when recorded request_id matches active tab + +HistoryPanel.replay_requested(int entry_id) + → MainWindow._replay_request_history_entry + → RequestHistoryService.build_send_payload_from_entry + → _launch_http_send (auth_data=None; no pre/post scripts) + → ResponseViewer only; new history row on finish + → ResponseViewer ``responseReplayIndicator`` (source row link) + +ResponseViewer.replay_history_link_clicked(int entry_id) + → MainWindow._on_replay_history_link_clicked + → RightSidebar.open_panel("request_history") + → HistoryPanel.focus_entry(entry_id) + +HistoryPanel.delete_requested(int entry_id) + → MainWindow._delete_request_history_entry + → RequestHistoryService.delete_entry + → HistoryPanel.refresh() + +CollectionWidget.load_finished + → MainWindow._on_load_finished (main stack + menu/status) + → session_restore.begin_session_restore (batched tab restore) + → MainWindow.session_restore_finished + +MainWindow (startup) + → LocalProjectConfigWorker (QThread): ensure_local_project_config / sync_all ``` ### Folder editor flow diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 756b02e..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"9ea9398f-ac16-4afe-96c4-0200faa36aa0","pid":2209021,"procStart":"29210819","acquiredAt":1780086540080} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a3ba48e..c56db25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,8 +106,9 @@ Fastest paths to understand and navigate the codebase: - **All services at a glance:** Read `src/services/__init__.py` — re-exports `CollectionService`, `EnvironmentService`, `ImportService`, - `RunHistoryService`, and key TypedDicts (`RequestLoadDict`, - `VariableDetail`, `LocalOverride`). + `RunHistoryService`, `RequestHistoryService`, and key TypedDicts + (`RequestLoadDict`, `VariableDetail`, `LocalOverride`, + `RequestHistoryEntryDict`, `SendIdentityDict`). - **HTTP subsystem:** Read `src/services/http/__init__.py` — re-exports `HttpService`, `GraphQLSchemaService`, `SnippetGenerator`, `SnippetOptions`, `HttpResponseDict`, `parse_header_dict`. @@ -116,8 +117,8 @@ Fastest paths to understand and navigate the codebase: - **All DB models:** Read `src/database/database.py` — re-exports collection, environment, run-history, and local-script ORM models (`CollectionModel`, `RequestModel`, `SavedResponseModel`, `EnvironmentModel`, `RunHistoryModel`, - `RunResultModel`, `LocalScriptFolderModel`, `LocalScriptModel`, - `SnippetModel`). + `RunResultModel`, `RequestHistoryEntryModel`, `LocalScriptFolderModel`, + `LocalScriptModel`, `SnippetModel`). - **Collection CRUD vs queries:** Mutations live in `collection_repository.py`; read-only tree/breadcrumb/ancestor queries live in `collection_query_repository.py`. @@ -148,7 +149,8 @@ src/ ├── main.py # Entry point — configure_before_qapplication + QApplication + init_db() ├── qt_app_init.py # Hi-DPI bootstrap (before first QApplication; tests + app) ├── database/ # Engine, models, repository -│ ├── database.py # init_db(), get_session(), migration +│ ├── data_paths.py # project_root(), postmark_user_data_dir(), user_history_root() +│ ├── database.py # init_db(), get_session(), migration; reconcile_orphans on startup │ └── models/ │ ├── base.py # DeclarativeBase │ ├── collections/ @@ -187,6 +189,11 @@ src/ │ ├── request_assertion_repository.py # CRUD for declarative assertion rows │ └── model/ │ └── request_assertion_model.py # RequestAssertionModel (subject/operator/expected) +│ └── request_history/ +│ ├── body_store.py # Atomic body/snapshot files under user_history_root() +│ ├── request_history_repository.py # insert, get, list, prune, nullify_request_id +│ └── model/ +│ └── request_history_entry_model.py # RequestHistoryEntryModel (metadata in SQLite) ├── services/ # Service layer (UI ↔ DB bridge) │ ├── collection_service.py # CollectionService (static methods) │ ├── assertion_service.py # AssertionService + AssertionDict — declarative tests CRUD + compile @@ -195,6 +202,7 @@ src/ │ ├── environment_service.py # EnvironmentService (variable substitution + TypedDicts) │ ├── import_service.py # ImportService (parse + persist) │ ├── run_history_service.py # RunHistoryService (run history CRUD bridge) +│ ├── request_history_service.py # RequestHistoryService — gather_send_identity, record_send, get/list │ ├── script_service.py # ScriptService (script chain resolution) │ ├── scripting/ # Script execution sub-package │ │ ├── local_path_policy.py # Re-export path_policy (UI/service) @@ -285,6 +293,8 @@ src/ │ ├── send_pipeline_debug_session.py # on_debug_paused/step/finished, end_debug_ui │ ├── draft_controller.py # _DraftControllerMixin — draft tab open/save │ ├── tab_controller.py # _TabControllerMixin — tab open/close/switch + │ ├── session_restore.py # Batched session tab restore after load_finished + │ ├── startup_workers.py # LocalProjectConfigWorker — mirror sync off GUI thread │ ├── tab_nav/ # Tab activation back/forward stacks │ │ ├── history.py # _TabNavHistoryMixin — Go menu Ctrl+Alt+arrows │ │ └── __init__.py @@ -310,16 +320,23 @@ src/ │ ├── debug_panel.py # DebugPanel facade — DebugControls + DebugInspectorSplit │ ├── debug_call_stack_panel.py # CallStackPanel — frame list + frame_selected │ ├── debug_watch_in_tree.py # Watches section rows + format_watch_display / rebuild_watch_rows - │ └── 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 + │ ├── 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 + │ └── history/ # Per-request History flyout (right rail) + │ ├── panel.py # HistoryPanel — list/detail + requestHistorySearch + │ ├── panel_detail_tabs.py # Read-only Headers / Request Headers / Request Body tabs + │ ├── delegate.py # HistoryEntryDelegate — status badge + date group headers + │ ├── helpers.py # Date grouping, list populate, row meta, sent headers + │ └── search_filter.py # Re-exports body search mixin ├── styling/ # Visual theming and icons │ ├── theme.py # Palettes, colours, status bar / left-rail chrome, badge/tree geometry, left-nav panel margins, method_color(), status_color() │ ├── language_icons.py # Brand SVG pixmaps for JS / TS / Python tiles │ ├── theme_manager.py # ThemeManager — QPalette + QSettings │ ├── tab_settings_manager.py # TabSettingsManager — request-tab QSettings bridge (preview, limits, activate-on-close, wrap mode) + │ ├── history_settings_manager.py # HistorySettingsManager — QSettings history/* send retention │ ├── global_qss.py # build_global_qss() — global stylesheet builder │ └── icons.py # Phosphor font-glyph icon provider (phi()) ├── widgets/ # Reusable shared components @@ -390,6 +407,8 @@ src/ │ ├── tree_overlay_rename.py # _TreeOverlayRenameMixin — overlay rename + click-away │ └── collection_tree_delegate.py # Custom delegate for method badges ├── dialogs/ # Modal dialogs + │ ├── settings/ + │ │ └── history_page.py # Settings → History page (retention, bodies, storage path) │ ├── collection_runner/ │ │ ├── __init__.py # Re-exports RunnerConfigView, RunnerResultsView, RunnerWorker │ │ ├── config.py # RunnerConfigView (env selector, request checklist, data file, iterations, delay) @@ -453,6 +472,7 @@ src/ │ └── toolbar.py # _DiffToolbar — search, nav, whitespace, copy ├── response_viewer/ # ResponseViewer sub-package │ ├── viewer_widget.py # ResponseViewer — response display widget + │ ├── replay_indicator.py # ResponseReplayIndicator — replayed-send status corner pill │ ├── search_filter.py # _SearchFilterMixin — response search/filter │ ├── test_results_mixin.py # _TestResultsMixin — test results tab │ └── pre_request_mixin.py # _PreRequestMixin — pre-request script output tab @@ -481,7 +501,10 @@ tests/ │ │ ├── test_request_assertion_repository.py │ │ ├── test_script_version_local_script.py │ │ ├── test_environment_repository.py -│ │ └── test_run_history_repository.py +│ │ ├── test_run_history_repository.py +│ │ ├── test_data_paths.py +│ │ ├── test_request_history_body_store.py +│ │ └── test_request_history_repository.py │ └── services/ # Service layer tests │ ├── test_service.py │ ├── test_environment_service.py @@ -503,6 +526,7 @@ tests/ │ ├── test_assertions_compiler.py │ ├── test_deno_manager.py │ ├── test_runtime_settings.py +│ ├── test_request_history_service.py │ └── http/ # HTTP service tests │ ├── test_http_service.py │ ├── test_graphql_schema_service.py diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 41202ca..16b1757 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -112,11 +112,12 @@ MainWindow._on_send() main.py 1. QApplication() 2. ThemeManager / TabSettingsManager / load_font() - 3. MainWindow() -- loading screen shown; main UI built with WA_DontShowOnScreen - 4. Collection fetch worker runs init_db() before fetch_all() - 5. load_finished -- clears DontShowOnScreen, switches stack to main UI - 6. window.showMaximized() - 7. app.exec() + 3. init_db() -- on the main thread before any window + 4. MainWindow() -- loading screen; local mirror sync starts on a worker thread + 5. window.showMaximized() + 6. Collection fetch worker runs init_db() (no-op) then fetch_all() + 7. load_finished -- switches stack to main UI; session tabs restore in batches + 8. app.exec() ``` ## Further Reading diff --git a/docs/ui-reference/sidebar.md b/docs/ui-reference/sidebar.md index 9ccd63a..4dd0cf6 100644 --- a/docs/ui-reference/sidebar.md +++ b/docs/ui-reference/sidebar.md @@ -112,6 +112,7 @@ Always-visible fixed-width icon rail. | Variables | `{}` | Read-only variable list | | Code Snippet | `<>` | Code snippet generator | | Saved Responses | `[]` | Saved response browser | +| History | clock (counter-clockwise) | Per-request send log (read-only) | ### Key Attributes @@ -128,13 +129,14 @@ Always-visible fixed-width icon rail. | `load_variables(variables, local_overrides)` | Refresh variables panel | | `load_snippet_for_request(request_id)` | Refresh snippet (live on change) | | `load_saved_responses(request_id)` | Refresh saved responses list | +| `set_request_history_context(...)` | Configure History panel for active request | | `install_in_splitter(splitter)` | Place flyout and rail as splitter children | ## FlyoutPanel Collapsible content area as a splitter child. -Contains three stacked panels with a title bar and close button. +Contains four stacked panels with a title bar and close button. The flyout can snap closed via its splitter handle. ## VariablesPanel @@ -278,3 +280,47 @@ response name, and timestamp. ### Search and Filter (_PanelSearchFilterMixin) Filter by name and search within response bodies. + +## HistoryPanel + +Read-only list/detail flyout for HTTP sends recorded for the **active saved +request** (not draft tabs). + +Source: `src/ui/sidebar/history/` + +### objectNames + +| Widget | objectName | +|--------|------------| +| Panel | `requestHistoryPanel` | +| Search field | `requestHistorySearch` | +| Tree | `requestHistoryTree` | +| Replay | `requestHistoryReplayButton` | + +Refresh uses the flyout title-bar icon (same row as the panel close button), not +a control inside the panel body. + +### Behaviour + +- Persisted request: `RequestHistoryService.list_for_request(request_id)`; a + `QTreeWidget` groups sends under local calendar days (Today, Yesterday, or a + formatted date) with expand/collapse like the collections tree. +- `requestHistorySearch` filters by URL/name/method substring and exact HTTP status + when the term is all digits (e.g. `200`, `400`). +- Draft tab: empty state — save the request first (does not list global draft sends) +- Detail tabs: Body, Headers, Request Headers, Request Body (from stored snapshot) +- `refresh_requested` → `refresh()` reloads metadata; full bodies loaded on selection +- **Replay** (white icon, top-right of the detail header): sends the stored snapshot over + the network and updates the centre **response** pane only — the request editor + (URL, params, headers, body) is left unchanged. Context menu on send rows: + **Replay this request**, **Delete item**. +- After a replay completes, the **response viewer** shows a `responseReplayIndicator` + pill (left of the status badge) with a link to open this panel and select the + **source** send that was replayed. + +### Signals + +| Signal | Parameters | Description | +|--------|------------|-------------| +| `refresh_requested` | *(none)* | Reload list for current request | +| `replay_requested` | `entry_id: int` | Replay the selected send (response pane only) | diff --git a/scripts/close_stuck_qt_test_windows.py b/scripts/close_stuck_qt_test_windows.py new file mode 100644 index 0000000..9174938 --- /dev/null +++ b/scripts/close_stuck_qt_test_windows.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Close desktop windows left behind by pytest/Qt UI tests. + +Run after a stuck test run:: + + poetry run python scripts/close_stuck_qt_test_windows.py + +If windows remain, stop stray pytest workers first:: + + pkill -f 'pytest.*postmark' || true +""" + +from __future__ import annotations + +import os +import sys + +_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(_ROOT, "src")) +sys.path.insert(0, _ROOT) + + +def main() -> int: + """Close orphan top-level widgets in the current QApplication.""" + from PySide6.QtWidgets import QApplication + + from qt_app_init import configure_before_qapplication + from tests.qt_popup_cleanup import dismiss_all_top_level_test_widgets + + configure_before_qapplication() + app = QApplication.instance() + if not isinstance(app, QApplication): + app = QApplication(sys.argv) + before = len(app.topLevelWidgets()) + dismiss_all_top_level_test_widgets(app) + after = len(app.topLevelWidgets()) + closed = max(0, before - after) + print(f"Closed {closed} top-level widget(s); {after} remain (including QApplication).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/profile_startup.py b/scripts/profile_startup.py index 206b8d3..45a1a11 100644 --- a/scripts/profile_startup.py +++ b/scripts/profile_startup.py @@ -125,10 +125,13 @@ def main() -> None: profiler.enable() window.collection_widget.load_finished.emit() + from ui.main_window.session_restore import flush_session_restore + + flush_session_restore(window) profiler.disable() - _snapshot("Phase 4 — load_finished + _restore_tabs", t4, mem4) + _snapshot("Phase 4 — load_finished + restore_tabs", t4, mem4) # -- Phase 5: show() ------------------------------------------------- mem5 = _rss_mb() diff --git a/src/AGENTS.md b/src/AGENTS.md index c65c1f2..00c16af 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -87,6 +87,14 @@ RequestEditorWidget ──_on_fetch_schema──► SchemaFetchWorker (QThread - `RunHistoryService` follows the same `@staticmethod` pattern. It wraps `run_history_repository` for run history CRUD (create, finish, add result, query runs/results, delete). +- `RequestHistoryService` (`request_history_service.py`) persists HTTP **send** + history: `gather_send_identity` at send start, `record_send` at the end of + `on_send_finished` (skipped when `_suppress_history_record` is set during + debug replay). Settings come from `HistorySettingsManager` (`history/*` + QSettings). Bodies and request snapshots live under `user_history_root()`; + metadata in `request_history_entries`. **Replay** uses + `build_send_payload_from_entry` (snapshot + `sent_headers`; no editor auth); + `delete_entry` removes row and payload files. - `ScriptService` and `ScriptEngine` also follow the `@staticmethod` pattern. `ScriptService.build_script_chain(request_id)` walks the ancestor chain to collect inherited scripts. `ScriptEngine` dispatches @@ -607,8 +615,10 @@ into `%Y-%m-%d %H:%M` strings for the UI. active index after every tab open/close/reorder and in `closeEvent`. **Environments** tabs are saved as ``{"type": "environments"}`` (no id) and recreated on restore in their saved order. - **Deferred tab materialisation:** `_restore_tabs()` restores tabs - lazily after `CollectionWidget.load_finished` fires. Request tabs + **Deferred tab materialisation:** `session_restore.begin_session_restore` + (via `_restore_tabs()`) restores tabs in batches after + `CollectionWidget.load_finished` so the GUI thread stays responsive. + Request tabs with `method` and `name` in the session data are created as lightweight tab-bar chips stored in `_deferred_tabs`; the editor and viewer widgets are built on first selection via diff --git a/src/database/AGENTS.md b/src/database/AGENTS.md index fbca1f4..42deaa3 100644 --- a/src/database/AGENTS.md +++ b/src/database/AGENTS.md @@ -221,6 +221,35 @@ Core ORM models, all inheriting from `Base`: | `LocalScriptModel` | `local_scripts` | `database/models/local_scripts/model/local_script_model.py` | | `SnippetModel` | `snippets` | `database/models/snippets/model/snippet_model.py` | | `RequestAssertionModel` | `request_assertions` | `database/models/request_assertions/model/request_assertion_model.py` | +| `RequestHistoryEntryModel` | `request_history_entries` | `database/models/request_history/model/request_history_entry_model.py` — includes `was_persisted_request` (required on disk in some DBs) | + +### Path helpers (`data_paths.py`) + +| Helper | Location | +|--------|----------| +| `project_root()` | Repository root (parent of `src/`). Default SQLite: `project_root()/data/database/main.db` | +| `postmark_user_data_dir()` | OS user-data folder only — **not** the project DB | +| `user_history_root()` | `{postmark_user_data_dir}/history` — response bodies + request snapshots | + +### Request send history — metadata vs files + +HTTP send history stores **metadata** in the **project** SQLite (`request_history_entries` +in `data/database/main.db`) and **payloads** under the OS user-data directory: + +| Path helper | Purpose | +|-------------|---------| +| `postmark_user_data_dir()` | `~/.local/share/postmark` (Linux), `~/Library/Application Support/postmark` (macOS), `%LOCALAPPDATA%/postmark` (Windows) | +| `user_history_root()` | `{postmark_user_data_dir}/history` | +| `bodies/{id}.bin` | Response body bytes (relative paths stored in DB) | +| `requests/{id}.json` | Request snapshot JSON at send time | + +`init_db()` runs `migrate_legacy_paths_and_files()` (imports from project +``data/history`` when present, normalizes DB paths) then `reconcile_orphans()`. +Orphan cleanup **skips** when body files exist but the DB table is empty (avoids +wiping real payloads during isolated test DB init). +`delete_request()` calls `nullify_request_id()` so history rows survive with +`request_id = NULL`. Repository: `request_history_repository.py`; file I/O: +`body_store.py`. ### Local scripts — `module_format` @@ -237,7 +266,7 @@ in the service layer. ### Re-exports in database.py `database.py` re-exports collection, environment, run-history, local-script, -snippet, and request-assertion models using the `import X as X` pattern +snippet, request-assertion, and request-history models using the `import X as X` pattern (PEP 484 explicit re-export) so that `Base.metadata.create_all()` discovers every table. These imports must remain even though `database.py` itself does not use the models directly. Include ``LocalScriptFolderModel`` and diff --git a/src/database/data_paths.py b/src/database/data_paths.py new file mode 100644 index 0000000..67d0f6a --- /dev/null +++ b/src/database/data_paths.py @@ -0,0 +1,42 @@ +"""Shared path helpers for the project tree and per-user Postmark data.""" + +from __future__ import annotations + +import os +import platform +from pathlib import Path + + +def project_root() -> Path: + """Return the repository root (parent of ``src/``). + + This module lives at ``src/database/data_paths.py``, so the repo root is + two levels up — not ``parents[1]`` (that would be ``src/`` alone). + """ + return Path(__file__).resolve().parents[2] + + +def postmark_user_data_dir() -> Path: + """Return the OS-native Postmark user-data directory (created if missing). + + Linux: ``$XDG_DATA_HOME/postmark`` or ``~/.local/share/postmark`` + macOS: ``~/Library/Application Support/postmark`` + Windows: ``%LOCALAPPDATA%/postmark`` + """ + system = platform.system() + if system == "Darwin": + base = Path.home() / "Library" / "Application Support" + elif system == "Windows": + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + else: + base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) + root = base / "postmark" + root.mkdir(parents=True, exist_ok=True) + return root + + +def user_history_root() -> Path: + """Return ``postmark_user_data_dir() / "history"`` (created if missing).""" + root = postmark_user_data_dir() / "history" + root.mkdir(parents=True, exist_ok=True) + return root diff --git a/src/database/database.py b/src/database/database.py index d23281f..32cb4d5 100644 --- a/src/database/database.py +++ b/src/database/database.py @@ -24,6 +24,9 @@ from .models import SavedResponseModel as SavedResponseModel from .models import ScriptVersionModel as ScriptVersionModel from .models import SnippetModel as SnippetModel +from .models.request_history.model.request_history_entry_model import ( + RequestHistoryEntryModel as RequestHistoryEntryModel, +) from .models.base import Base logger = logging.getLogger(__name__) @@ -52,8 +55,9 @@ def init_db(db_path: Path | None = None) -> None: return if db_path is None: - project_root = Path(__file__).resolve().parents[2] - db_path = project_root / "data" / "database" / "main.db" + from database.data_paths import project_root + + db_path = project_root() / "data" / "database" / "main.db" os.makedirs(db_path.parent, exist_ok=True) database_url = f"sqlite:///{db_path}" @@ -75,6 +79,12 @@ def init_db(db_path: Path | None = None) -> None: _SessionLocal = sessionmaker( bind=_engine, autoflush=False, autocommit=False, expire_on_commit=False ) + + from database.models.request_history import body_store + + body_store.migrate_legacy_paths_and_files() + body_store.reconcile_orphans() + logger.info("Database initialised: %s", database_url) diff --git a/src/database/models/__init__.py b/src/database/models/__init__.py index ad6db09..93f9ea9 100644 --- a/src/database/models/__init__.py +++ b/src/database/models/__init__.py @@ -18,6 +18,7 @@ from .runs.model.run_result_model import RunResultModel from .request_assertions.model.request_assertion_model import RequestAssertionModel from .script_versions.model.script_version_model import ScriptVersionModel +from .request_history.model.request_history_entry_model import RequestHistoryEntryModel from .snippets.model.snippet_model import SnippetModel __all__ = [ @@ -26,6 +27,7 @@ "LocalScriptFolderModel", "LocalScriptModel", "RequestAssertionModel", + "RequestHistoryEntryModel", "RequestModel", "RunHistoryModel", "RunResultModel", diff --git a/src/database/models/collections/collection_repository.py b/src/database/models/collections/collection_repository.py index 1b09bc2..891de47 100644 --- a/src/database/models/collections/collection_repository.py +++ b/src/database/models/collections/collection_repository.py @@ -134,6 +134,9 @@ def create_new_request( def delete_request(request_id: int) -> None: """Delete the request identified by *request_id*.""" + from database.models.request_history import request_history_repository + + request_history_repository.nullify_request_id(request_id) with get_session() as session: req = session.get(RequestModel, request_id) if req is None: diff --git a/src/database/models/request_history/__init__.py b/src/database/models/request_history/__init__.py new file mode 100644 index 0000000..8e223ce --- /dev/null +++ b/src/database/models/request_history/__init__.py @@ -0,0 +1,5 @@ +"""Request send history persistence (SQLite metadata + user-data files).""" + +from __future__ import annotations + +from .model.request_history_entry_model import RequestHistoryEntryModel as RequestHistoryEntryModel diff --git a/src/database/models/request_history/body_store.py b/src/database/models/request_history/body_store.py new file mode 100644 index 0000000..7dec16e --- /dev/null +++ b/src/database/models/request_history/body_store.py @@ -0,0 +1,257 @@ +"""File-backed storage for request-history response bodies and request snapshots.""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +from contextlib import suppress +from pathlib import Path + +import database.data_paths as data_paths +from database.database import get_session + +from .model.request_history_entry_model import RequestHistoryEntryModel + +logger = logging.getLogger(__name__) + + +def normalize_history_relative_path(relative_path: str | None) -> str | None: + """Strip a legacy ``history/`` prefix mistakenly stored in SQLite paths.""" + if not relative_path: + return None + path = relative_path.replace("\\", "/").lstrip("/") + while path.startswith("history/"): + path = path[len("history/") :] + return path or None + + +def _legacy_project_history_root() -> Path: + """Return the pre-user-data history directory under the project tree.""" + return data_paths.project_root() / "data" / "history" + + +def resolve_history_path(relative_path: str) -> Path: + """Resolve a DB-relative path under :func:`user_history_root`.""" + normalized = normalize_history_relative_path(relative_path) or relative_path + return data_paths.user_history_root() / normalized + + +def _read_candidates(relative_path: str | None) -> list[Path]: + """Return filesystem paths to try when loading a stored relative path.""" + if not relative_path: + return [] + normalized = normalize_history_relative_path(relative_path) + if not normalized: + return [] + candidates = [ + data_paths.user_history_root() / normalized, + _legacy_project_history_root() / normalized, + ] + raw = relative_path.replace("\\", "/").lstrip("/") + if raw != normalized: + candidates.append(_legacy_project_history_root() / raw) + # De-duplicate while preserving order. + seen: set[Path] = set() + unique: list[Path] = [] + for path in candidates: + if path not in seen: + seen.add(path) + unique.append(path) + return unique + + +def _bodies_dir() -> Path: + path = data_paths.user_history_root() / "bodies" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _requests_dir() -> Path: + path = data_paths.user_history_root() / "requests" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _atomic_write(target: Path, data: bytes) -> None: + """Write *data* to *target* via a same-directory temp file and ``os.replace``.""" + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.with_suffix(target.suffix + ".tmp") + tmp.write_bytes(data) + os.replace(tmp, target) + + +def write_body(entry_id: int, data: bytes) -> str: + """Write response body bytes; return relative path ``bodies/{id}.bin``.""" + rel = f"bodies/{entry_id}.bin" + _atomic_write(_bodies_dir() / f"{entry_id}.bin", data) + return rel + + +def read_body(relative_path: str | None) -> bytes | None: + """Read body bytes; return ``None`` when the file is missing.""" + for path in _read_candidates(relative_path): + if path.is_file(): + return path.read_bytes() + return None + + +def write_request_snapshot(entry_id: int, snap: dict) -> str: + """Write request snapshot JSON; return relative path ``requests/{id}.json``.""" + rel = f"requests/{entry_id}.json" + payload = json.dumps(snap, ensure_ascii=False, default=str).encode("utf-8") + _atomic_write(_requests_dir() / f"{entry_id}.json", payload) + return rel + + +def read_request_snapshot(relative_path: str | None) -> dict | None: + """Read request snapshot JSON; return ``None`` when missing or invalid.""" + for path in _read_candidates(relative_path): + if not path.is_file(): + continue + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + return loaded if isinstance(loaded, dict) else None + return None + + +def delete_file(relative_path: str | None) -> None: + """Best-effort unlink of a history file in all known storage locations.""" + if not relative_path: + return + for path in _read_candidates(relative_path): + with suppress(OSError): + path.unlink(missing_ok=True) + + +def _copy_legacy_file_to_user_store(relative_path: str) -> None: + """Copy a file from a legacy location into ``user_history_root`` when missing.""" + normalized = normalize_history_relative_path(relative_path) + if not normalized: + return + dest = data_paths.user_history_root() / normalized + if dest.is_file(): + return + for src in _read_candidates(relative_path): + if src.is_file() and src != dest: + _atomic_write(dest, src.read_bytes()) + return + + +def _copy_tree_if_missing(src_root: Path, dest_root: Path, subdir: str) -> None: + """Copy ``subdir`` files from *src_root* into *dest_root* when absent in dest.""" + src_dir = src_root / subdir + if not src_dir.is_dir(): + return + dest_dir = dest_root / subdir + dest_dir.mkdir(parents=True, exist_ok=True) + for src in src_dir.iterdir(): + if not src.is_file(): + continue + dest = dest_dir / src.name + if not dest.is_file(): + shutil.copy2(src, dest) + + +def _migrate_files_from_legacy_roots() -> None: + """Import bodies/requests from project ``data/history`` into the user-data store.""" + dest = data_paths.user_history_root() + src = _legacy_project_history_root() + if src.resolve() == dest.resolve(): + return + _copy_tree_if_missing(src, dest, "bodies") + _copy_tree_if_missing(src, dest, "requests") + + +def _ensure_row_files_present(row: RequestHistoryEntryModel) -> None: + """Copy missing payload files for one DB row from any legacy location.""" + for attr in ("response_body_path", "request_snapshot_path"): + rel = getattr(row, attr) + if rel: + _copy_legacy_file_to_user_store(str(rel)) + + +def migrate_legacy_paths_and_files() -> None: + """Normalize DB paths, import legacy trees, and backfill missing row files.""" + from sqlalchemy import select + + _migrate_files_from_legacy_roots() + with get_session() as session: + rows = list(session.execute(select(RequestHistoryEntryModel)).scalars().all()) + for row in rows: + for attr in ("response_body_path", "request_snapshot_path"): + old = getattr(row, attr) + new = normalize_history_relative_path(old) + if new and new != old: + setattr(row, attr, new) + _ensure_row_files_present(row) + session.flush() + + +def reconcile_orphans() -> None: + """Remove body/snapshot files that do not belong to any history row id.""" + bodies_dir = data_paths.user_history_root() / "bodies" + requests_dir = data_paths.user_history_root() / "requests" + if not bodies_dir.is_dir() and not requests_dir.is_dir(): + return + + from sqlalchemy import select + + with get_session() as session: + rows = list(session.execute(select(RequestHistoryEntryModel)).scalars().all()) + known_entry_ids = {int(row.id) for row in rows} + + has_body_files = bodies_dir.is_dir() and any(bodies_dir.iterdir()) + if has_body_files and not known_entry_ids: + logger.warning( + "Skipping request-history orphan cleanup: payload files exist but " + "request_history_entries is empty" + ) + return + + if bodies_dir.is_dir(): + for path in bodies_dir.iterdir(): + if not path.is_file() or path.suffix != ".bin": + continue + try: + entry_id = int(path.stem) + except ValueError: + delete_file(f"bodies/{path.name}") + continue + if entry_id not in known_entry_ids: + delete_file(f"bodies/{path.name}") + if requests_dir.is_dir(): + for path in requests_dir.iterdir(): + if not path.is_file() or path.suffix != ".json": + continue + try: + entry_id = int(path.stem) + except ValueError: + delete_file(f"requests/{path.name}") + continue + if entry_id not in known_entry_ids: + delete_file(f"requests/{path.name}") + + +def verify_body_files() -> int: + """Log rows whose body files are missing on disk; return the missing count.""" + from sqlalchemy import select + + missing = 0 + with get_session() as session: + rows = list(session.execute(select(RequestHistoryEntryModel)).scalars().all()) + for row in rows: + if row.response_size_bytes <= 0: + continue + if read_body(row.response_body_path) is None: + missing += 1 + logger.debug( + "Request history entry %s body file missing (path=%r, size=%s)", + row.id, + row.response_body_path, + row.response_size_bytes, + ) + return missing diff --git a/src/database/models/request_history/model/request_history_entry_model.py b/src/database/models/request_history/model/request_history_entry_model.py new file mode 100644 index 0000000..89c449d --- /dev/null +++ b/src/database/models/request_history/model/request_history_entry_model.py @@ -0,0 +1,43 @@ +"""ORM model for persisted HTTP send history entries.""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, Float, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from database.models.base import Base + + +class RequestHistoryEntryModel(Base): + """One recorded main-window Send (metadata in SQLite; bodies on disk).""" + + __tablename__ = "request_history_entries" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + executed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + index=True, + server_default=func.now(), + ) + request_id: Mapped[int | None] = mapped_column(Integer, default=None, index=True) + was_persisted_request: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + request_name: Mapped[str] = mapped_column(String(255), default="") + method: Mapped[str] = mapped_column(String(10), default="GET") + url: Mapped[str] = mapped_column(Text, default="") + status_code: Mapped[int] = mapped_column(Integer, default=0) + elapsed_ms: Mapped[float] = mapped_column(Float, default=0.0) + error: Mapped[str | None] = mapped_column(Text, default=None) + response_headers: Mapped[list | dict | None] = mapped_column(JSON, default=None) + response_body_path: Mapped[str | None] = mapped_column(String(512), default=None) + body_truncated: Mapped[bool] = mapped_column(Boolean, default=False) + response_size_bytes: Mapped[int] = mapped_column(Integer, default=0) + request_snapshot_path: Mapped[str | None] = mapped_column(String(512), default=None) + + def __repr__(self) -> str: + """Return a developer-friendly string representation.""" + return ( + f"" + ) diff --git a/src/database/models/request_history/request_history_repository.py b/src/database/models/request_history/request_history_repository.py new file mode 100644 index 0000000..4900168 --- /dev/null +++ b/src/database/models/request_history/request_history_repository.py @@ -0,0 +1,253 @@ +"""Repository layer — CRUD for HTTP request send history.""" + +from __future__ import annotations + +import logging +from collections import defaultdict +from datetime import UTC, date, datetime, timedelta +from typing import Any + +from sqlalchemy import or_, select + +from database.database import get_session + +from . import body_store +from .model.request_history_entry_model import RequestHistoryEntryModel + +logger = logging.getLogger(__name__) + + +def local_date(executed_at: datetime) -> date: + """Return the local calendar date for *executed_at* (timezone-aware).""" + if executed_at.tzinfo is None: + executed_at = executed_at.replace(tzinfo=UTC) + return executed_at.astimezone().date() + + +def _entry_to_dict(row: RequestHistoryEntryModel) -> dict[str, Any]: + """Convert an ORM row to a plain dict (metadata only).""" + executed = row.executed_at + if executed.tzinfo is None: + executed = executed.replace(tzinfo=UTC) + return { + "id": row.id, + "executed_at": executed.isoformat(), + "request_id": row.request_id, + "was_persisted_request": row.was_persisted_request, + "request_name": row.request_name, + "method": row.method, + "url": row.url, + "status_code": row.status_code, + "elapsed_ms": row.elapsed_ms, + "error": row.error, + "response_headers": row.response_headers, + "response_body_path": row.response_body_path, + "body_truncated": row.body_truncated, + "response_size_bytes": row.response_size_bytes, + "request_snapshot_path": row.request_snapshot_path, + } + + +def insert_entry( + *, + request_id: int | None, + request_name: str, + method: str, + url: str, + status_code: int, + elapsed_ms: float, + error: str | None, + response_headers: list[Any] | dict[str, Any] | None, + response_body: bytes | None, + original_request: dict[str, Any] | None, + save_responses: bool, + max_response_bytes: int, + retention_days: int, + max_items_per_day: int, + unlimited_per_day: bool, +) -> dict[str, Any]: + """Insert metadata, write disk files, update paths, and prune old rows.""" + body_truncated = False + response_size_bytes = 0 + body_path: str | None = None + snapshot_path: str | None = None + + if response_body is not None and save_responses: + response_size_bytes = len(response_body) + to_write = response_body + if len(to_write) > max_response_bytes: + to_write = to_write[:max_response_bytes] + body_truncated = True + else: + to_write = None + + with get_session() as session: + row = RequestHistoryEntryModel( + executed_at=datetime.now(tz=UTC), + request_id=request_id, + was_persisted_request=request_id is not None, + request_name=request_name, + method=method, + url=url, + status_code=status_code, + elapsed_ms=elapsed_ms, + error=error, + response_headers=response_headers if save_responses else None, + body_truncated=body_truncated, + response_size_bytes=response_size_bytes, + ) + session.add(row) + session.flush() + entry_id = row.id + + if to_write is not None: + body_path = body_store.write_body(entry_id, to_write) + row.response_body_path = body_path + if body_store.read_body(body_path) is None: + raise RuntimeError(f"request history body file not written for entry {entry_id}") + if original_request is not None: + try: + snapshot_path = body_store.write_request_snapshot(entry_id, original_request) + row.request_snapshot_path = snapshot_path + except (TypeError, ValueError) as exc: + logger.warning( + "Request history %s: could not serialize request snapshot: %s", + entry_id, + exc, + ) + session.flush() + result = _entry_to_dict(row) + + prune_old_entries( + retention_days=retention_days, + max_items_per_day=max_items_per_day, + unlimited_per_day=unlimited_per_day, + ) + return result + + +def delete_entry(entry_id: int) -> bool: + """Delete one history row and its on-disk payload files.""" + with get_session() as session: + row = session.get(RequestHistoryEntryModel, entry_id) + if row is None: + return False + _delete_rows(session, [row]) + return True + + +def get_entry(entry_id: int) -> dict[str, Any] | None: + """Load one entry with body bytes and request snapshot attached.""" + with get_session() as session: + row = session.get(RequestHistoryEntryModel, entry_id) + if row is None: + return None + data = _entry_to_dict(row) + body_bytes = body_store.read_body(data.get("response_body_path")) + data["body"] = body_bytes + data["original_request"] = body_store.read_request_snapshot(data.get("request_snapshot_path")) + return data + + +def list_entries_for_sidebar(*, search: str = "", limit: int = 500) -> list[dict[str, Any]]: + """Return metadata rows newest-first; optional SQL search over all retained rows.""" + term = search.strip() + with get_session() as session: + stmt = select(RequestHistoryEntryModel).order_by( + RequestHistoryEntryModel.executed_at.desc(), + RequestHistoryEntryModel.id.desc(), + ) + stmt = _apply_history_search(stmt, term) if term else stmt.limit(limit) + rows = list(session.execute(stmt).scalars().all()) + return [_entry_to_dict(row) for row in rows] + + +def _apply_history_search(stmt: Any, term: str) -> Any: + """Filter history rows by URL, name, method, or exact status code.""" + pattern = f"%{term}%" + clauses: list[Any] = [ + RequestHistoryEntryModel.request_name.ilike(pattern), + RequestHistoryEntryModel.url.ilike(pattern), + RequestHistoryEntryModel.method.ilike(pattern), + ] + if term.isdigit(): + clauses.append(RequestHistoryEntryModel.status_code == int(term)) + return stmt.where(or_(*clauses)) + + +def list_for_request( + request_id: int, + *, + search: str = "", + limit: int = 200, +) -> list[dict[str, Any]]: + """Return metadata rows for one saved request, newest first.""" + term = search.strip() + with get_session() as session: + stmt = ( + select(RequestHistoryEntryModel) + .where(RequestHistoryEntryModel.request_id == request_id) + .order_by( + RequestHistoryEntryModel.executed_at.desc(), + RequestHistoryEntryModel.id.desc(), + ) + ) + stmt = _apply_history_search(stmt, term) if term else stmt.limit(limit) + rows = list(session.execute(stmt).scalars().all()) + return [_entry_to_dict(row) for row in rows] + + +def _delete_rows(session: Any, rows: list[RequestHistoryEntryModel]) -> None: + """Unlink on-disk files and delete ORM rows.""" + for row in rows: + body_store.delete_file(row.response_body_path) + body_store.delete_file(row.request_snapshot_path) + session.delete(row) + + +def prune_old_entries( + *, + retention_days: int, + max_items_per_day: int, + unlimited_per_day: bool, +) -> None: + """Drop rows older than *retention_days* and enforce per-local-day caps.""" + cutoff = datetime.now(tz=UTC) - timedelta(days=max(1, retention_days)) + with get_session() as session: + stmt = select(RequestHistoryEntryModel).where(RequestHistoryEntryModel.executed_at < cutoff) + stale = list(session.execute(stmt).scalars().all()) + if stale: + _delete_rows(session, stale) + + if not unlimited_per_day: + stmt_all = select(RequestHistoryEntryModel).order_by( + RequestHistoryEntryModel.executed_at.asc(), + RequestHistoryEntryModel.id.asc(), + ) + all_rows = list(session.execute(stmt_all).scalars().all()) + by_day: dict[date, list[RequestHistoryEntryModel]] = defaultdict(list) + for row in all_rows: + executed = row.executed_at + if executed.tzinfo is None: + executed = executed.replace(tzinfo=UTC) + by_day[local_date(executed)].append(row) + to_drop: list[RequestHistoryEntryModel] = [] + cap = max(1, max_items_per_day) + for day_rows in by_day.values(): + if len(day_rows) > cap: + to_drop.extend(day_rows[: len(day_rows) - cap]) + if to_drop: + _delete_rows(session, to_drop) + + +def nullify_request_id(request_id: int) -> None: + """Clear ``request_id`` on history rows when the saved request is deleted.""" + from sqlalchemy import update + + with get_session() as session: + stmt = ( + update(RequestHistoryEntryModel) + .where(RequestHistoryEntryModel.request_id == request_id) + .values(request_id=None) + ) + session.execute(stmt) diff --git a/src/main.py b/src/main.py index 594fefd..1deb9af 100644 --- a/src/main.py +++ b/src/main.py @@ -12,6 +12,7 @@ from services.lsp.server_registry import LspRegistry from ui.main_window import MainWindow from ui.styling.icons import load_font +from ui.styling.history_settings_manager import HistorySettingsManager from ui.styling.tab_settings_manager import TabSettingsManager from ui.styling.theme_manager import ThemeManager @@ -27,6 +28,7 @@ # Apply theme (reads QSettings, sets style + palette + global QSS) theme_manager = ThemeManager(app) tab_settings_manager = TabSettingsManager(app) + history_settings_manager = HistorySettingsManager(app) # Load the Phosphor icon font (must happen after QApplication) load_font() @@ -35,13 +37,11 @@ # Initialise the database before any widget accesses it init_db() - from services.scripting.local_scripts_project.deno_config import ensure_local_project_config - - ensure_local_project_config() window = MainWindow( theme_manager=theme_manager, tab_settings_manager=tab_settings_manager, + history_settings_manager=history_settings_manager, ) window.showMaximized() ret = app.exec() diff --git a/src/services/__init__.py b/src/services/__init__.py index bb1e1f7..2909878 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -11,6 +11,11 @@ from services.collection_service import CollectionService, RequestLoadDict from services.environment_service import EnvironmentService, LocalOverride, VariableDetail from services.import_service import ImportService +from services.request_history_service import ( + RequestHistoryEntryDict, + RequestHistoryService, + SendIdentityDict, +) from services.run_history_service import RunHistoryService from services.script_service import ScriptService from services.script_version_service import ScriptVersionService @@ -29,6 +34,8 @@ "EnvironmentService", "ImportService", "LocalOverride", + "RequestHistoryEntryDict", + "RequestHistoryService", "RequestLoadDict", "RunHistoryService", "ScriptEngine", @@ -37,6 +44,7 @@ "ScriptOutput", "ScriptService", "ScriptVersionService", + "SendIdentityDict", "TestResult", "VariableDetail", ] diff --git a/src/services/request_history_service.py b/src/services/request_history_service.py new file mode 100644 index 0000000..452bf3f --- /dev/null +++ b/src/services/request_history_service.py @@ -0,0 +1,345 @@ +"""Service layer for persisted HTTP send history.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypedDict, cast + +from database.models.request_history import request_history_repository +from ui.styling.history_settings_manager import HistorySettingsManager + + +class HistorySendPayloadDict(TypedDict): + """HTTP payload for replaying a send without loading the editor.""" + + method: str + url: str + headers: str | None + body: str | None + history_snapshot: dict[str, Any] + + +class RequestHistoryEntryDict(TypedDict, total=False): + """A request history row with optional loaded file payloads.""" + + id: int + executed_at: str + request_id: int | None + request_name: str + method: str + url: str + status_code: int + elapsed_ms: float + error: str | None + response_headers: list[Any] | dict[str, Any] | None + response_body_path: str | None + body_truncated: bool + response_size_bytes: int + request_snapshot_path: str | None + body: bytes | None + original_request: dict[str, Any] | None + source_label: str | None + + +class SendIdentityDict(TypedDict): + """Identity fields captured at send time.""" + + request_id: int | None + request_name: str + method: str + url: str + + +class PendingHistoryContextDict(TypedDict): + """Send-time tab context used when the HTTP worker finishes.""" + + request_id: int | None + request_name: str + method: str + url: str + tab_type: str + + +def gather_send_identity(ctx: Any, editor: Any, data: dict[str, Any]) -> SendIdentityDict: + """Build send identity from tab context, editor, and worker payload.""" + from services.collection_service import CollectionService + + method = str(data.get("request_method") or editor._method_combo.currentText()) + url = str(data.get("request_url") or editor._url_input.text()).strip() + request_id = ctx.request_id if ctx is not None else None + request_name = "" + if ctx is not None and ctx.request_id: + req_model = CollectionService.get_request(ctx.request_id) + if req_model is not None: + request_name = str(req_model.name or "") + elif ctx is not None and ctx.draft_name: + request_name = str(ctx.draft_name) + return SendIdentityDict( + request_id=request_id, + request_name=request_name, + method=method, + url=url, + ) + + +def _body_bytes_from_response(data: dict[str, Any]) -> bytes | None: + """Extract response body as bytes for file storage.""" + if "error" in data: + return None + body = data.get("body") + if body is None: + return b"" + if isinstance(body, bytes): + return body + return str(body).encode("utf-8", errors="replace") + + +def _source_label(request_id: int | None, request_name: str) -> str | None: + """Return a muted UI label for unattached rows (metadata only). + + Rows with ``request_id is NULL`` are either unsaved-tab sends or orphaned + after the collection request was deleted; v1 uses ``(deleted)`` when the + id is missing and a name was stored (draft sends are hidden on the + per-request rail). + """ + if request_id is None: + return "(deleted)" if request_name.strip() else "(draft)" + return None + + +def enrich_snapshot_for_history( + snapshot: dict[str, Any] | None, + response: dict[str, Any], +) -> dict[str, Any]: + """Merge editor snapshot with headers/URL/method actually sent (incl. auth).""" + merged: dict[str, Any] = dict(snapshot) if isinstance(snapshot, dict) else {} + sent_headers = response.get("request_headers") + if sent_headers: + merged["sent_headers"] = sent_headers + request_url = response.get("request_url") + if isinstance(request_url, str) and request_url.strip(): + merged["url"] = request_url + request_method = response.get("request_method") + if isinstance(request_method, str) and request_method.strip(): + merged["method"] = request_method + return merged + + +def record_send( + *, + identity: SendIdentityDict, + response: dict[str, Any], + original_request: dict[str, Any] | None, + settings: HistorySettingsManager, +) -> int | None: + """Persist one send to history; return new entry id or ``None`` on failure.""" + snapshot = enrich_snapshot_for_history(original_request, response) + error = response.get("error") + if error is not None: + status_code = 0 + elapsed_ms = 0.0 + err_text = str(error) + headers = None + body_bytes = None + else: + status_code = int(response.get("status_code", 0) or 0) + elapsed_ms = float(response.get("elapsed_ms", 0.0) or 0.0) + err_text = None + headers = response.get("headers") + body_bytes = _body_bytes_from_response(response) + + max_bytes = settings.max_response_bytes_for_storage() + row = request_history_repository.insert_entry( + request_id=identity.get("request_id"), + request_name=str(identity.get("request_name", "")), + method=str(identity.get("method", "GET")), + url=str(identity.get("url", "")), + status_code=status_code, + elapsed_ms=elapsed_ms, + error=err_text, + response_headers=headers if settings.save_responses else None, + response_body=body_bytes, + original_request=snapshot, + save_responses=settings.save_responses, + max_response_bytes=max_bytes if max_bytes > 0 else settings.max_response_bytes, + retention_days=settings.retention_days, + max_items_per_day=settings.max_items_per_day, + unlimited_per_day=settings.unlimited_per_day, + ) + return int(row["id"]) + + +def _entries_with_labels(rows: list[dict[str, Any]]) -> list[RequestHistoryEntryDict]: + """Attach ``source_label`` to repository metadata rows.""" + out: list[RequestHistoryEntryDict] = [] + for row in rows: + entry = cast(RequestHistoryEntryDict, dict(row)) + entry["source_label"] = _source_label( + row.get("request_id"), str(row.get("request_name", "")) + ) + out.append(entry) + return out + + +def list_for_sidebar(search: str = "") -> list[RequestHistoryEntryDict]: + """List all history metadata (global sidebar; newest first).""" + rows = request_history_repository.list_entries_for_sidebar(search=search, limit=500) + return _entries_with_labels(rows) + + +def list_for_request(request_id: int, search: str = "") -> list[RequestHistoryEntryDict]: + """List send history for one persisted request.""" + rows = request_history_repository.list_for_request(request_id, search=search, limit=200) + return _entries_with_labels(rows) + + +def get_entry(entry_id: int) -> RequestHistoryEntryDict | None: + """Load a full history entry including file payloads.""" + row = request_history_repository.get_entry(entry_id) + if row is None: + return None + entry = cast(RequestHistoryEntryDict, dict(row)) + entry["source_label"] = _source_label(row.get("request_id"), str(row.get("request_name", ""))) + return entry + + +def build_replay_request_dict(entry: RequestHistoryEntryDict) -> dict[str, Any]: + """Build editor load data from a stored snapshot and row metadata.""" + snap = entry.get("original_request") + data: dict[str, Any] = dict(snap) if isinstance(snap, dict) else {} + if not str(data.get("method", "")).strip(): + data["method"] = str(entry.get("method", "GET")) + if not str(data.get("url", "")).strip(): + data["url"] = str(entry.get("url", "")) + if not str(data.get("name", "")).strip(): + data["name"] = str(entry.get("request_name", "")) + return data + + +def can_replay_entry(entry: RequestHistoryEntryDict) -> bool: + """Return True when *entry* has enough data to replay a send.""" + return bool(str(build_replay_request_dict(entry).get("url", "")).strip()) + + +def _headers_text_from_snapshot(snapshot: Mapping[str, Any]) -> str: + """Return newline header text from ``sent_headers`` or snapshot header rows.""" + sent = snapshot.get("sent_headers") + if sent: + if isinstance(sent, dict): + return "\n".join(f"{key}: {value}" for key, value in sent.items()) + if isinstance(sent, str): + return sent + if isinstance(sent, list): + return "\n".join( + f"{row.get('key', '')}: {row.get('value', '')}" + for row in sent + if isinstance(row, dict) and row.get("key") + ) + headers = snapshot.get("headers") + if isinstance(headers, list) and headers: + lines: list[str] = [] + for row in headers: + if not isinstance(row, dict) or not row.get("enabled", True): + continue + key = str(row.get("key", "")).strip() + if not key: + continue + lines.append(f"{key}: {row.get('value', '')}") + return "\n".join(lines) + return "" + + +def build_send_payload_from_entry( + entry: RequestHistoryEntryDict, +) -> HistorySendPayloadDict | None: + """Build worker inputs from a stored snapshot (headers as actually sent).""" + snap = build_replay_request_dict(entry) + url = str(snap.get("url", "")).strip() + if not url: + return None + method = str(snap.get("method", "GET") or "GET") + headers = _headers_text_from_snapshot(snap) + body_val = snap.get("body") + body: str | None + if body_val is None: + body = None + else: + body = str(body_val) + if not body: + body = None + return HistorySendPayloadDict( + method=method, + url=url, + headers=headers or None, + body=body, + history_snapshot=snap, + ) + + +def delete_entry(entry_id: int) -> bool: + """Delete one send-history row and its payload files.""" + return request_history_repository.delete_entry(entry_id) + + +def entry_for_replay(entry_id: int) -> RequestHistoryEntryDict | None: + """Load a history row for replay; return ``None`` when missing or not replayable.""" + entry = get_entry(entry_id) + if entry is None or not can_replay_entry(entry): + return None + return entry + + +def replay_source_link_text(entry: RequestHistoryEntryDict) -> str: + """Short link label for the response viewer replay banner.""" + from ui.sidebar.history.helpers import format_executed_at + + method = str(entry.get("method", "GET") or "GET") + code = entry.get("status_code") + status_part = f" {code}" if code is not None else "" + executed = str(entry.get("executed_at", "")) + when = format_executed_at(executed) if executed else "earlier send" + return f"View {method}{status_part} ({when})" + + +def entry_to_detail_snapshot(entry: RequestHistoryEntryDict) -> dict[str, Any]: + """Shape a history row for read-only detail panes (future sidebar).""" + body_bytes = entry.get("body") + body_text = "" + if body_bytes: + body_text = body_bytes.decode("utf-8", errors="replace") + elif entry.get("response_size_bytes"): + body_text = "[Response body unavailable — history file missing from storage]" + + headers = entry.get("response_headers") or [] + original = entry.get("original_request") or {} + return { + "status_code": entry.get("status_code", 0), + "status_text": "", + "method": entry.get("method", ""), + "url": entry.get("url", ""), + "elapsed_ms": entry.get("elapsed_ms", 0.0), + "error": entry.get("error"), + "headers": headers, + "body": body_text, + "body_truncated": entry.get("body_truncated", False), + "original_request": original, + "source_label": entry.get("source_label"), + } + + +class RequestHistoryService: + """Static façade for request send history (project convention).""" + + gather_send_identity = staticmethod(gather_send_identity) + enrich_snapshot_for_history = staticmethod(enrich_snapshot_for_history) + record_send = staticmethod(record_send) + list_for_sidebar = staticmethod(list_for_sidebar) + list_for_request = staticmethod(list_for_request) + get_entry = staticmethod(get_entry) + build_replay_request_dict = staticmethod(build_replay_request_dict) + build_send_payload_from_entry = staticmethod(build_send_payload_from_entry) + delete_entry = staticmethod(delete_entry) + can_replay_entry = staticmethod(can_replay_entry) + entry_for_replay = staticmethod(entry_for_replay) + entry_to_detail_snapshot = staticmethod(entry_to_detail_snapshot) + replay_source_link_text = staticmethod(replay_source_link_text) diff --git a/src/ui/AGENTS.md b/src/ui/AGENTS.md index 71d05d2..2cbf849 100644 --- a/src/ui/AGENTS.md +++ b/src/ui/AGENTS.md @@ -271,6 +271,14 @@ standard object names: | `leftSidebarRail` | `QWidget` | Left activity rail: background uses palette ``status_bar_bg`` (same as ``QStatusBar#appStatusBar``); no outer layout padding | | `leftSidebarRailButton` | `QToolButton` | Rail icon (``_LeftRailButton``): width ``round(LEFT_RAIL_WIDTH_EM * em)``, icon ``round(LEFT_RAIL_ICON_EM * em)``, height ``icon_size + LEFT_RAIL_BUTTON_EXTRA_HEIGHT_PX``; checked left accent **painted** full height (``LEFT_RAIL_ACCENT_STRIPE_WIDTH_PX``); QSS margin/padding ``0`` | | `sidebarPanelArea` | `QWidget` | Right sidebar collapsible flyout panel (separate splitter child) | +| `requestHistoryPanel` | `HistoryPanel` | Per-request History flyout (right rail, 4th button) | +| `requestHistorySearch` | `QLineEdit` | Filter History by URL substring or status code (e.g. `200`) | +| `requestHistoryList` | `QStackedWidget` | Bordered list area (tree or no-match empty state) | +| `requestHistoryTree` | `QTreeWidget` | History tree inside `requestHistoryList` (date groups → sends) | +| `requestHistoryReplayButton` | `QPushButton` | Replay selected send (detail header; snapshot-only HTTP, response pane only) | +| `responseReplayIndicator` | `QFrame` | Response viewer corner pill when the current response is from a history replay | +| `responseReplayPrefix` | `QLabel` | Muted "Replayed request" label inside `responseReplayIndicator` | +| `responseReplayLink` | `ClickableLabel` | Link to open History and select the source row | | `leftSidebarFlyout` | `QWidget` | Left collections flyout: ``border-left`` vs rail only; right edge uses the main splitter handle (no ``border-right``, avoids a double line when open). At 0 width uses a local ``setStyleSheet`` to strip chrome. Nav horizontal inset lives on ``CollectionWidget`` / ``EnvironmentSidebarPanel`` (``LEFT_NAV_PANEL_MARGIN_H_*`` in ``theme.py``) so the collections|environments splitter handle is not inset. | | `sidebarTitleLabel` | `QLabel` | Bold panel title in **right** flyout header; debug panel position label (left flyout has no title row) | | `variableKeyLabel` | `QLineEdit` | Variable key (read-only, selectable) in variables / debug KV rows | diff --git a/src/ui/dialogs/settings/__init__.py b/src/ui/dialogs/settings/__init__.py new file mode 100644 index 0000000..0483432 --- /dev/null +++ b/src/ui/dialogs/settings/__init__.py @@ -0,0 +1,3 @@ +"""Settings dialog sub-pages.""" + +from __future__ import annotations diff --git a/src/ui/dialogs/settings/history_page.py b/src/ui/dialogs/settings/history_page.py new file mode 100644 index 0000000..12319b0 --- /dev/null +++ b/src/ui/dialogs/settings/history_page.py @@ -0,0 +1,141 @@ +"""Settings dialog — History category page.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from PySide6.QtWidgets import ( + QCheckBox, + QHBoxLayout, + QLabel, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from database.data_paths import postmark_user_data_dir, project_root +from ui.styling.history_settings_manager import ( + DEFAULT_MAX_ITEMS_PER_DAY, + DEFAULT_MAX_RESPONSE_BYTES, + DEFAULT_RETENTION_DAYS, + MAX_MAX_ITEMS_PER_DAY, + MAX_MAX_RESPONSE_BYTES, + MAX_RETENTION_DAYS, + MIN_MAX_ITEMS_PER_DAY, + MIN_RETENTION_DAYS, + HistorySettingsManager, +) + + +@dataclass +class HistoryPageWidgets: + """Widgets on the History settings page.""" + + retention_days_spin: QSpinBox + max_items_spin: QSpinBox + unlimited_check: QCheckBox + save_responses_check: QCheckBox + max_mib_spin: QSpinBox + storage_path_label: QLabel + + +def build_history_page( + history_settings: HistorySettingsManager, +) -> tuple[QWidget, HistoryPageWidgets]: + """Build the History detail page and return it with widget handles.""" + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(12) + + heading = QLabel("History") + heading.setObjectName("titleLabel") + layout.addWidget(heading) + + storage_path_label = QLabel(f"Files are stored under:\n{postmark_user_data_dir() / 'history'}") + storage_path_label.setObjectName("mutedLabel") + storage_path_label.setWordWrap(True) + layout.addWidget(storage_path_label) + + db_path = project_root() / "data" / "database" / "main.db" + privacy = QLabel( + f"Request metadata (headers, method, URL) is stored in the project database:\n" + f"{db_path}\n\n" + "Response bodies and request snapshots (including auth) are saved as " + "plaintext files under the user-data path above. Worst-case disk use is " + "roughly retention days x max items per day x max response size." + ) + privacy.setObjectName("mutedLabel") + privacy.setWordWrap(True) + layout.addWidget(privacy) + + retention_row = QHBoxLayout() + retention_row.addWidget(QLabel("Keep history for (days):")) + retention_days_spin = QSpinBox() + retention_days_spin.setRange(MIN_RETENTION_DAYS, MAX_RETENTION_DAYS) + retention_days_spin.setValue(history_settings.retention_days) + retention_row.addWidget(retention_days_spin) + retention_row.addStretch() + layout.addLayout(retention_row) + + unlimited_check = QCheckBox("Unlimited entries per day") + unlimited_check.setChecked(history_settings.unlimited_per_day) + layout.addWidget(unlimited_check) + + max_row = QHBoxLayout() + max_row.addWidget(QLabel("Max entries per day:")) + max_items_spin = QSpinBox() + max_items_spin.setRange(MIN_MAX_ITEMS_PER_DAY, MAX_MAX_ITEMS_PER_DAY) + max_items_spin.setValue(history_settings.max_items_per_day) + max_items_spin.setEnabled(not history_settings.unlimited_per_day) + max_row.addWidget(max_items_spin) + max_row.addStretch() + layout.addLayout(max_row) + + save_responses_check = QCheckBox("Save response bodies") + save_responses_check.setChecked(history_settings.save_responses) + layout.addWidget(save_responses_check) + + mib_row = QHBoxLayout() + mib_row.addWidget(QLabel("Max response body size (MiB):")) + max_mib_spin = QSpinBox() + max_mib_spin.setRange(1, MAX_MAX_RESPONSE_BYTES // (1024 * 1024)) + max_mib_spin.setValue(max(1, history_settings.max_response_bytes // (1024 * 1024))) + mib_row.addWidget(max_mib_spin) + mib_row.addStretch() + layout.addLayout(mib_row) + + layout.addStretch() + + widgets = HistoryPageWidgets( + retention_days_spin=retention_days_spin, + max_items_spin=max_items_spin, + unlimited_check=unlimited_check, + save_responses_check=save_responses_check, + max_mib_spin=max_mib_spin, + storage_path_label=storage_path_label, + ) + return page, widgets + + +def apply_history_page( + history_settings: HistorySettingsManager, + widgets: HistoryPageWidgets, +) -> None: + """Persist widget values into *history_settings*.""" + history_settings.retention_days = widgets.retention_days_spin.value() + history_settings.unlimited_per_day = widgets.unlimited_check.isChecked() + history_settings.max_items_per_day = widgets.max_items_spin.value() + history_settings.save_responses = widgets.save_responses_check.isChecked() + mib = widgets.max_mib_spin.value() + history_settings.max_response_bytes = mib * 1024 * 1024 + + +def load_history_page_defaults(widgets: HistoryPageWidgets) -> None: + """Reset widgets to built-in defaults (for tests).""" + widgets.retention_days_spin.setValue(DEFAULT_RETENTION_DAYS) + widgets.max_items_spin.setValue(DEFAULT_MAX_ITEMS_PER_DAY) + widgets.unlimited_check.setChecked(False) + widgets.max_items_spin.setEnabled(True) + widgets.save_responses_check.setChecked(True) + widgets.max_mib_spin.setValue(DEFAULT_MAX_RESPONSE_BYTES // (1024 * 1024)) diff --git a/src/ui/dialogs/settings_dialog.py b/src/ui/dialogs/settings_dialog.py index 4679e43..e3b35f2 100644 --- a/src/ui/dialogs/settings_dialog.py +++ b/src/ui/dialogs/settings_dialog.py @@ -46,6 +46,12 @@ ) from services.scripting.secret_store import backend_status from ui.styling.icons import phi +from ui.dialogs.settings.history_page import ( + HistoryPageWidgets, + apply_history_page, + build_history_page, +) +from ui.styling.history_settings_manager import HistorySettingsManager from ui.styling.tab_settings_manager import ( ACTIVATE_LEFT, ACTIVATE_MRU, @@ -80,6 +86,7 @@ def __init__( parent: QWidget | None = None, *, initial_category: str = "Appearance", + history_settings_manager: HistorySettingsManager | None = None, ) -> None: """Initialise the settings dialog. @@ -108,6 +115,8 @@ def __init__( self._tm = theme_manager self._tab_settings = tab_settings_manager or TabSettingsManager(self) + self._history_settings = history_settings_manager or HistorySettingsManager(self) + self._history_widgets: HistoryPageWidgets | None = None self._deno_download_thread: QThread | None = None self._deno_download_worker: DenoDownloadWorker | None = None @@ -145,6 +154,7 @@ def __init__( self._build_appearance_page() self._build_tabs_page() self._build_scripting_page() + self._build_history_page() self._build_private_packages_pages() self._populate_category_tree() @@ -206,6 +216,7 @@ def _leaf(parent: QTreeWidget | QTreeWidgetItem, label: str, idx: int) -> QTreeW appearance = _leaf(self._cat_tree, "Appearance", self._page_indices["appearance"]) _leaf(self._cat_tree, "Tabs", self._page_indices["tabs"]) _leaf(self._cat_tree, "Scripting", self._page_indices["scripting"]) + _leaf(self._cat_tree, "History", self._page_indices["history"]) private_parent = QTreeWidgetItem(["Private packages"]) private_parent.setData(0, Qt.ItemDataRole.UserRole, self._page_indices["private_overview"]) @@ -228,6 +239,7 @@ def _apply_initial_category(self, name: str) -> None: "appearance": "Appearance", "tabs": "Tabs", "scripting": "Scripting", + "history": "History", "private packages": "Private packages", "private": "Private packages", "npm": "npm", @@ -566,6 +578,23 @@ def _build_scripting_page(self) -> None: layout.addStretch() self._page_indices["scripting"] = self._stack.addWidget(page) + def _build_history_page(self) -> None: + """Build the History settings page.""" + page, widgets = build_history_page(self._history_settings) + self._history_widgets = widgets + widgets.unlimited_check.toggled.connect(self._on_history_unlimited_toggled) + widgets.unlimited_check.toggled.connect(self._mark_dirty) + widgets.retention_days_spin.valueChanged.connect(self._mark_dirty) + widgets.max_items_spin.valueChanged.connect(self._mark_dirty) + widgets.save_responses_check.toggled.connect(self._mark_dirty) + widgets.max_mib_spin.valueChanged.connect(self._mark_dirty) + self._page_indices["history"] = self._stack.addWidget(page) + + def _on_history_unlimited_toggled(self, checked: bool) -> None: + """Enable or disable the per-day cap spinbox.""" + if self._history_widgets is not None: + self._history_widgets.max_items_spin.setEnabled(not checked) + # -- Private packages pages ---------------------------------------- _REG_COLS = ("Scope", "Registry URL", "Auth") # Type column dropped — implied by page. @@ -1610,6 +1639,9 @@ def _do_apply(self) -> None: self._refresh_deno_status() self._refresh_python_status() + if self._history_widgets is not None: + apply_history_page(self._history_settings, self._history_widgets) + # Private packages: persist registry list, default-npm, PyPI. # B6 fix: drop invalid rows (empty scope/URL or non-https URL) # explicitly on Apply rather than letting ``get_registries()`` diff --git a/src/ui/main_window/draft_controller.py b/src/ui/main_window/draft_controller.py index b3a3bff..591143d 100644 --- a/src/ui/main_window/draft_controller.py +++ b/src/ui/main_window/draft_controller.py @@ -51,6 +51,7 @@ class _DraftControllerMixin: def _on_send_request(self) -> None: ... def _on_save_request(self) -> None: ... def _on_save_response(self, data: dict) -> None: ... + def _on_replay_history_link_clicked(self, entry_id: int) -> None: ... def _sync_save_btn(self, dirty: bool) -> None: ... def _on_editor_dirty_changed(self, dirty: bool) -> None: ... def _on_tab_changed(self, index: int) -> None: ... @@ -129,6 +130,7 @@ def _open_draft_request(self) -> None: editor.dirty_changed.connect(self._sync_save_btn) editor.dirty_changed.connect(self._on_editor_dirty_changed) viewer.save_response_requested.connect(self._on_save_response) + viewer.replay_history_link_clicked.connect(self._on_replay_history_link_clicked) # Mark as dirty so Save button is enabled for the new draft editor._set_dirty(True) diff --git a/src/ui/main_window/send_pipeline.py b/src/ui/main_window/send_pipeline.py index 6d77821..8be41f3 100644 --- a/src/ui/main_window/send_pipeline.py +++ b/src/ui/main_window/send_pipeline.py @@ -99,6 +99,8 @@ class _SendPipelineMixin: response_widget: ResponseViewerWidget _debug_protocol: DebugProtocol | None _inline_test_run: dict[str, Any] | None + _pending_request_snapshot: dict[str, Any] | None + _pending_history_context: Any def _current_tab_context(self) -> TabContext | None: ... @@ -130,13 +132,17 @@ def _on_send_request(self) -> None: if ctx is not None: editor = ctx.require_editor() viewer = ctx.require_response_viewer() + ctx.replay_source_entry_id = None + viewer.clear_replay_history_source() else: editor = self.request_widget viewer = self.response_widget + viewer.clear_replay_history_source() method = editor._method_combo.currentText() url = editor._url_input.text().strip() if not url: + self._clear_pending_history_capture() viewer.show_error("URL is empty") inline_test = getattr(self, "_inline_test_run", None) if inline_test is not None: @@ -161,8 +167,6 @@ def _on_send_request(self) -> None: if inherited: auth_data = inherited - env_id = self._env_selector.current_environment_id() - request_id = ctx.request_id if ctx else None request_name = "" if ctx and ctx.request_id: @@ -174,23 +178,16 @@ def _on_send_request(self) -> None: elif ctx and ctx.draft_name: request_name = str(ctx.draft_name) - # 3. Tear down any previous send thread - if ctx is not None: - ctx.cleanup_thread() - else: - self._cleanup_send_thread() - - # 4. Show loading state, spinner, and toggle button to Cancel - viewer.show_loading() - self._set_send_button_cancel(True) - if ctx is not None: - idx = self._tab_bar.currentIndex() - self._tab_bar.update_tab(idx, is_sending=True) + self._pending_request_snapshot = editor.get_request_data() + self._pending_history_context = { + "request_id": request_id, + "request_name": request_name, + "method": method, + "url": url, + "tab_type": ctx.tab_type if ctx is not None else "request", + } - # 5. Create worker — variable resolution + auth on worker thread - # 5a. Resolve script chain for this request from services.script_service import ScriptService - from ui.request.http_worker import HttpSendWorker inline_test = getattr(self, "_inline_test_run", None) pre_scripts = None @@ -205,7 +202,6 @@ def _on_send_request(self) -> None: test_scripts, ) else: - # Draft: use inline scripts from the editor scripts_data = editor.get_request_data().get("scripts") if scripts_data: pre_scripts, test_scripts = ScriptService.build_collection_script_chain( @@ -227,6 +223,78 @@ def _on_send_request(self) -> None: message_prefix="Pre-request script", ) + self._launch_http_send( + ctx, + viewer=viewer, + method=method, + url=url, + headers=headers, + body=body, + auth_data=auth_data, + request_id=request_id, + request_name=request_name, + pre_scripts=pre_scripts, + test_scripts=test_scripts, + declarative_test_script=declarative_test_script, + ) + + def run_post_response_script_with_live_response( + self, + *, + editor: RequestEditorWidget, + panel: ScriptOutputPanel, + script: str, + language: str, + run_btn: Any, + debug_btn: Any, + ) -> None: + """Send the active request first, then run one post-response script inline.""" + _impl_run_post_response( + self, + editor=editor, + panel=panel, + script=script, + language=language, + run_btn=run_btn, + debug_btn=debug_btn, + ) + + def _clear_pending_history_capture(self) -> None: + """Drop send-time history capture after record or cancel.""" + self._pending_request_snapshot = None + self._pending_history_context = None + + def _launch_http_send( + self, + ctx: TabContext | None, + *, + viewer: Any, + method: str, + url: str, + headers: str | None, + body: str | None, + auth_data: dict | None, + request_id: int | None, + request_name: str, + pre_scripts: list[Any] | None = None, + test_scripts: list[Any] | None = None, + declarative_test_script: Any = None, + ) -> None: + """Start ``HttpSendWorker`` without modifying the request editor.""" + from ui.request.http_worker import HttpSendWorker + + if ctx is not None: + ctx.cleanup_thread() + else: + self._cleanup_send_thread() + + viewer.show_loading() + self._set_send_button_cancel(True) + if ctx is not None: + idx = self._tab_bar.currentIndex() + self._tab_bar.update_tab(idx, is_sending=True) + + env_id = self._env_selector.current_environment_id() worker = HttpSendWorker() worker.set_request( method=method, @@ -240,14 +308,13 @@ def _on_send_request(self) -> None: local_overrides={k: v["value"] for k, v in ctx.local_overrides.items()} if ctx else None, - pre_scripts=pre_scripts, - test_scripts=test_scripts, + pre_scripts=pre_scripts or [], + test_scripts=test_scripts or [], declarative_test_script=declarative_test_script, ) thread = QThread() worker.moveToThread(thread) - thread.started.connect(worker.run) worker.finished.connect(self._on_send_finished) worker.error.connect(self._on_send_error) @@ -263,27 +330,93 @@ def _on_send_request(self) -> None: self._send_worker = worker thread.start() - def run_post_response_script_with_live_response( - self, - *, - editor: RequestEditorWidget, - panel: ScriptOutputPanel, - script: str, - language: str, - run_btn: Any, - debug_btn: Any, - ) -> None: - """Send the active request first, then run one post-response script inline.""" - _impl_run_post_response( - self, - editor=editor, - panel=panel, - script=script, - language=language, - run_btn=run_btn, - debug_btn=debug_btn, + def _replay_request_history_entry(self, entry_id: int) -> None: + """Replay a history send: HTTP from snapshot, response pane only.""" + from services.request_history_service import RequestHistoryService + + ctx = self._current_tab_context() + panel = getattr(self, "_request_history_panel", None) + if ctx is None or ctx.tab_type != "request" or ctx.request_id is None: + return + if panel is not None and panel._request_id != ctx.request_id: + return + if ctx.is_sending: + return + + entry = RequestHistoryService.entry_for_replay(entry_id) + if entry is None: + status = self.statusBar() if hasattr(self, "statusBar") else None + if status is not None: + status.showMessage("Cannot replay: request snapshot is missing a URL", 6000) + return + + payload = RequestHistoryService.build_send_payload_from_entry(entry) + if payload is None: + return + + viewer = ctx.require_response_viewer() + ctx.replay_source_entry_id = entry_id + self._pending_request_snapshot = payload["history_snapshot"] + self._pending_history_context = { + "request_id": ctx.request_id, + "request_name": str(entry.get("request_name", "")), + "method": payload["method"], + "url": payload["url"], + "tab_type": "request", + } + + self._launch_http_send( + ctx, + viewer=viewer, + method=payload["method"], + url=payload["url"], + headers=payload["headers"], + body=payload["body"], + auth_data=None, + request_id=ctx.request_id, + request_name=str(entry.get("request_name", "")), + pre_scripts=[], + test_scripts=[], + declarative_test_script=None, ) + def _on_replay_history_link_clicked(self, entry_id: int) -> None: + """Open send history and select the source row for a replayed response.""" + ctx = self._current_tab_context() + if ctx is None or ctx.tab_type != "request" or ctx.request_id is None: + return + panel = getattr(self, "_request_history_panel", None) + if panel is None: + return + if panel._request_id != ctx.request_id: + _, request_name = self._tab_bar.tab_request_info(self._tab_bar.currentIndex()) + self._right_sidebar.set_request_history_context( + request_id=ctx.request_id, + request_name=request_name, + is_persisted_request=True, + ) + self._right_sidebar.open_panel("request_history") + if not panel.focus_entry(entry_id): + status = self.statusBar() if hasattr(self, "statusBar") else None + if status is not None: + status.showMessage("History entry is no longer available", 5000) + + def _delete_request_history_entry(self, entry_id: int) -> None: + """Remove one send-history row and refresh the panel.""" + from services.request_history_service import RequestHistoryService + + ctx = self._current_tab_context() + panel = getattr(self, "_request_history_panel", None) + if panel is None: + return + if ctx is not None and ctx.request_id is not None and panel._request_id != ctx.request_id: + return + if not RequestHistoryService.delete_entry(entry_id): + return + if panel._current_entry_id == entry_id: + panel._current_entry_id = None + panel.refresh() + def _on_send_finished(self, data: dict) -> None: """Handle a successful HTTP response from the worker thread.""" _impl_on_send_finished(self, data) @@ -294,6 +427,9 @@ def _on_send_error(self, message: str) -> None: ctx = self._current_tab_context() viewer = ctx.require_response_viewer() if ctx is not None else self.response_widget viewer.show_error(message) + from ui.main_window.send_pipeline_postresponse import _apply_replay_indicator + + _apply_replay_indicator(self, ctx, viewer) self._set_send_button_cancel(False) if ctx is not None: idx = self._tab_bar.currentIndex() @@ -301,6 +437,9 @@ def _on_send_error(self, message: str) -> None: ctx.cleanup_thread() else: self._cleanup_send_thread() + from ui.main_window.send_pipeline_postresponse import _record_request_history + + _record_request_history(self, ctx, {"error": message}) self._refresh_sidebar() if inline_test is not None: panel = inline_test.get("panel") @@ -329,6 +468,7 @@ def _cancel_send(self) -> None: self._send_worker.cancel() self.response_widget.show_error("Request cancelled") self._cleanup_send_thread() + self._clear_pending_history_capture() self._set_send_button_cancel(False) def _set_send_button_cancel(self, is_cancel: bool) -> None: diff --git a/src/ui/main_window/send_pipeline_debug_session.py b/src/ui/main_window/send_pipeline_debug_session.py index 9eaf5db..0698537 100644 --- a/src/ui/main_window/send_pipeline_debug_session.py +++ b/src/ui/main_window/send_pipeline_debug_session.py @@ -137,7 +137,11 @@ def on_debug_step(window: Any, mode_name: str) -> None: def on_debug_finished(window: Any, data: dict) -> None: """Handle completion of a debug send.""" window._debug_protocol = None - window._on_send_finished(data) + window._suppress_history_record = True + try: + window._on_send_finished(data) + finally: + window._suppress_history_record = False end_debug_ui(window) diff --git a/src/ui/main_window/send_pipeline_postresponse.py b/src/ui/main_window/send_pipeline_postresponse.py index 67d7a80..3331097 100644 --- a/src/ui/main_window/send_pipeline_postresponse.py +++ b/src/ui/main_window/send_pipeline_postresponse.py @@ -5,13 +5,113 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: from ui.request.request_editor import RequestEditorWidget from ui.request.request_editor.scripts.output_panel import ScriptOutputPanel +def _apply_replay_indicator(window: Any, ctx: Any, viewer: Any) -> None: + """Show or clear the response viewer replay banner from tab context.""" + replay_id = getattr(ctx, "replay_source_entry_id", None) if ctx is not None else None + if replay_id is None: + viewer.clear_replay_history_source() + return + from services.request_history_service import RequestHistoryService + + entry = RequestHistoryService.get_entry(int(replay_id)) + if entry is not None: + viewer.set_replay_history_source( + int(replay_id), + RequestHistoryService.replay_source_link_text(entry), + ) + else: + viewer.clear_replay_history_source() + if ctx is not None: + ctx.replay_source_entry_id = None + + +def _record_request_history( + window: Any, + ctx: Any, + data: dict, +) -> None: + """Persist this send to request history when guards pass.""" + cap = getattr(window, "_pending_history_context", None) + tab_type = cap.get("tab_type") if isinstance(cap, dict) else None + if tab_type is None and ctx is not None: + tab_type = getattr(ctx, "tab_type", None) + if tab_type != "request": + if hasattr(window, "_clear_pending_history_capture"): + window._clear_pending_history_capture() + else: + window._pending_request_snapshot = None + window._pending_history_context = None + return + if getattr(window, "_suppress_history_record", False): + if hasattr(window, "_clear_pending_history_capture"): + window._clear_pending_history_capture() + return + settings = getattr(window, "_history_settings", None) + if settings is None: + if hasattr(window, "_clear_pending_history_capture"): + window._clear_pending_history_capture() + return + from services.request_history_service import ( + PendingHistoryContextDict, + RequestHistoryService, + SendIdentityDict, + ) + + identity: SendIdentityDict + if isinstance(cap, dict): + cap_dict = cast(PendingHistoryContextDict, cap) + identity = SendIdentityDict( + request_id=cap_dict.get("request_id"), + request_name=str(cap_dict.get("request_name", "")), + method=str(data.get("request_method") or cap_dict.get("method", "GET")), + url=str(data.get("request_url") or cap_dict.get("url", "")).strip(), + ) + elif ctx is not None: + editor = ctx.require_editor() + identity = RequestHistoryService.gather_send_identity(ctx, editor, data) + else: + if hasattr(window, "_clear_pending_history_capture"): + window._clear_pending_history_capture() + return + + snapshot = getattr(window, "_pending_request_snapshot", None) + try: + entry_id = RequestHistoryService.record_send( + identity=identity, + response=data, + original_request=snapshot if isinstance(snapshot, dict) else None, + settings=settings, + ) + if entry_id is not None: + panel = getattr(window, "_request_history_panel", None) + recorded_id = identity.get("request_id") + active_id = cap.get("request_id") if isinstance(cap, dict) else None + if active_id is None and ctx is not None: + active_id = ctx.request_id + if panel is not None and recorded_id is not None and active_id == recorded_id: + panel.refresh() + except Exception: + import logging + + logging.getLogger(__name__).exception("Failed to record request history") + status = window.statusBar() if hasattr(window, "statusBar") else None + if status is not None: + status.showMessage("History was not saved (see application log)", 8000) + finally: + if hasattr(window, "_clear_pending_history_capture"): + window._clear_pending_history_capture() + else: + window._pending_request_snapshot = None + window._pending_history_context = None + + def run_post_response_script_with_live_response( window: Any, *, @@ -51,6 +151,7 @@ def on_send_finished(window: Any, data: dict) -> None: ) viewer.load_response(data) + _apply_replay_indicator(window, ctx, viewer) test_results = data.get("test_results", []) console_logs = data.get("console_logs", []) @@ -97,6 +198,7 @@ def on_send_finished(window: Any, data: dict) -> None: else: window._cleanup_send_thread() window._refresh_sidebar() + _record_request_history(window, ctx, data) if inline_test is not None: from ui.request.request_editor.scripts.script_run_worker import build_inline_context diff --git a/src/ui/main_window/session_restore.py b/src/ui/main_window/session_restore.py new file mode 100644 index 0000000..944c590 --- /dev/null +++ b/src/ui/main_window/session_restore.py @@ -0,0 +1,169 @@ +"""Incremental session tab restore so startup stays responsive on the GUI thread.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from PySide6.QtCore import QTimer + +from services.local_script_service import LocalScriptService + +if TYPE_CHECKING: + from ui.main_window.window import MainWindow + +logger = logging.getLogger(__name__) + +# Tabs restored per event-loop tick (request chips are cheap; folder/draft cost more). +_RESTORE_BATCH_SIZE = 2 + + +@dataclass +class _SessionRestoreState: + """Queued session-restore work between timer ticks.""" + + data: dict[str, Any] + active: int + queue: list[dict[str, Any]] = field(default_factory=list) + + +def begin_session_restore(window: MainWindow) -> None: + """Schedule batched tab restore after ``load_finished`` (non-blocking).""" + state = _plan_session_restore(window) + if state is None: + return + window._session_restore_state = state + window._restoring_session = True + QTimer.singleShot(0, lambda: _restore_step(window)) + + +def flush_session_restore(window: MainWindow) -> None: + """Run all pending restore steps synchronously (tests).""" + while getattr(window, "_session_restore_state", None) is not None: + _restore_step(window) + + +def restore_tabs_synchronous(window: MainWindow) -> None: + """Restore the full session on the GUI thread (profiling / legacy callers).""" + state = _plan_session_restore(window) + if state is None: + return + window._restoring_session = True + try: + for entry in state.queue: + _apply_restore_entry(window, entry) + finally: + window._restoring_session = False + _finalize_session_restore(window, state) + if hasattr(window, "session_restore_finished"): + window.session_restore_finished.emit() + + +def _plan_session_restore(window: MainWindow) -> _SessionRestoreState | None: + """Load persisted tab data and build the restore queue.""" + data = window._tab_settings_manager.load_open_tabs() + if data is None: + window._left_sidebar.open_panel() + return None + + tabs_list = data.get("tabs") + if not isinstance(tabs_list, list): + return None + + active = data.get("active", 0) + if not isinstance(active, int): + active = 0 + + queue: list[dict[str, Any]] = [] + for entry in tabs_list: + if isinstance(entry, dict): + queue.append(entry) + + return _SessionRestoreState(data=data, active=active, queue=queue) + + +def _restore_step(window: MainWindow) -> None: + """Restore up to ``_RESTORE_BATCH_SIZE`` tabs, then yield to the event loop.""" + state = getattr(window, "_session_restore_state", None) + if state is None: + return + + batch = 0 + while state.queue and batch < _RESTORE_BATCH_SIZE: + entry = state.queue.pop(0) + _apply_restore_entry(window, entry) + batch += 1 + + if state.queue: + QTimer.singleShot(0, lambda: _restore_step(window)) + return + + window._session_restore_state = None + window._restoring_session = False + _finalize_session_restore(window, state) + if hasattr(window, "session_restore_finished"): + window.session_restore_finished.emit() + + +def _apply_restore_entry(window: MainWindow, entry: dict[str, Any]) -> None: + """Restore a single persisted tab entry.""" + tab_type = entry.get("type") + if tab_type == "draft": + window._restore_draft(entry) + return + if tab_type == "environments": + if window._find_environments_tab_index() is not None: + return + if not window._enforce_tab_limit_before_open(): + logger.warning( + "Skipping environments tab restore: tab limit reached", + ) + return + window._materialize_environments_tab_at(window._tab_bar.count()) + return + + item_id = entry.get("id") + if not isinstance(item_id, int): + return + + if tab_type == "request": + window._restore_request_deferred(entry, item_id) + elif tab_type == "folder": + window._open_folder(item_id, show_missing_warning=False) + elif tab_type == "local_script": + if LocalScriptService.get_script(item_id) is None: + return + window._restore_local_script_deferred(entry, item_id) + + +def _finalize_session_restore(window: MainWindow, state: _SessionRestoreState) -> None: + """Activate the saved tab and restore sidebar flyout state.""" + active = state.active + if 0 <= active < window._tab_bar.count(): + window._tab_bar.setCurrentIndex(active) + window._on_tab_changed(active) + window._flush_tab_change() + + window._seed_tab_nav_after_restore() + + data = state.data + left_panel = data.get("left_sidebar_panel") + if isinstance(left_panel, str): + window._left_sidebar.open_panel(left_panel) + elif not window._left_sidebar.is_open: + window._left_sidebar.open_panel() + + sidebar_panel = data.get("sidebar_panel") + if isinstance(sidebar_panel, str): + window._right_sidebar.open_panel(sidebar_panel) + sidebar_width = data.get("sidebar_width") + if isinstance(sidebar_width, int) and sidebar_width > 0: + window._right_sidebar._expand_flyout(sidebar_width) + + +__all__ = [ + "begin_session_restore", + "flush_session_restore", + "restore_tabs_synchronous", +] diff --git a/src/ui/main_window/startup_workers.py b/src/ui/main_window/startup_workers.py new file mode 100644 index 0000000..7494e1d --- /dev/null +++ b/src/ui/main_window/startup_workers.py @@ -0,0 +1,21 @@ +"""Background workers for deferred application startup tasks.""" + +from __future__ import annotations + +from PySide6.QtCore import QObject, Signal + + +class LocalProjectConfigWorker(QObject): + """Runs ``ensure_local_project_config`` (ambient types + local mirror sync) off the GUI thread.""" + + finished = Signal() + + def run(self) -> None: + """Write ambient stubs and sync the Deno ``local/`` mirror from the database.""" + from services.scripting.local_scripts_project.deno_config import ensure_local_project_config + + ensure_local_project_config() + self.finished.emit() + + +__all__ = ["LocalProjectConfigWorker"] diff --git a/src/ui/main_window/tab_controller.py b/src/ui/main_window/tab_controller.py index 8593e06..780cead 100644 --- a/src/ui/main_window/tab_controller.py +++ b/src/ui/main_window/tab_controller.py @@ -80,6 +80,7 @@ class _TabControllerMixin: def _on_send_request(self) -> None: ... def _on_save_request(self) -> None: ... def _on_save_response(self, data: dict) -> None: ... + def _on_replay_history_link_clicked(self, entry_id: int) -> None: ... def _sync_save_btn(self, dirty: bool) -> None: ... def _current_tab_context(self) -> TabContext | None: ... def _on_run_collection_by_id(self, collection_id: int) -> None: ... @@ -239,6 +240,7 @@ def _create_tab( editor.request_changed.connect(lambda _: self._schedule_sidebar_snippet_refresh()) editor.scripts_tab_active_changed.connect(self._on_editor_scripts_tab_changed) viewer.save_response_requested.connect(self._on_save_response) + viewer.replay_history_link_clicked.connect(self._on_replay_history_link_clicked) viewer.save_availability_changed.connect(lambda _enabled: self._refresh_sidebar()) # Now switch to the tab (triggers _on_tab_changed safely) @@ -703,81 +705,13 @@ def _persist_open_tabs(self) -> None: self._tab_settings_manager.save_open_tabs(data) def _restore_tabs(self) -> None: - """Restore tabs from the last session after collections have loaded. - - Request tabs are restored **lazily**: only a lightweight tab-bar - chip is created upfront. The actual editor and response viewer - widgets are materialised on first selection via - :meth:`_materialise_deferred_tab`. Draft and folder tabs are - still created eagerly because they require immediate state - (editor snapshot / folder metadata). **Environments** tabs are - materialised eagerly (no database id); each ``{"type": "environments"}`` - entry creates the global editor widget. - """ - data = self._tab_settings_manager.load_open_tabs() - if data is None: - self._left_sidebar.open_panel() - return - - tabs_list = data.get("tabs") - if not isinstance(tabs_list, list): - return - - active = data.get("active", 0) - - # Suppress per-tab persist calls — the data is already saved. - self._restoring_session = True - try: - for entry in tabs_list: - if not isinstance(entry, dict): - continue - tab_type = entry.get("type") - if tab_type == "draft": - self._restore_draft(entry) - continue - if tab_type == "environments": - if self._find_environments_tab_index() is not None: - continue - if not self._enforce_tab_limit_before_open(): - logger.warning( - "Skipping environments tab restore: tab limit reached", - ) - continue - self._materialize_environments_tab_at(self._tab_bar.count()) - continue - item_id = entry.get("id") - if not isinstance(item_id, int): - continue - if tab_type == "request": - self._restore_request_deferred(entry, item_id) - elif tab_type == "folder": - self._open_folder(item_id, show_missing_warning=False) - elif tab_type == "local_script": - if LocalScriptService.get_script(item_id) is None: - continue - self._restore_local_script_deferred(entry, item_id) - finally: - self._restoring_session = False - - if isinstance(active, int) and 0 <= active < self._tab_bar.count(): - self._tab_bar.setCurrentIndex(active) - self._on_tab_changed(active) - self._flush_tab_change() - - self._seed_tab_nav_after_restore() + """Restore tabs from the last session (batched; see ``session_restore``).""" + from typing import cast - left_panel = data.get("left_sidebar_panel") - if isinstance(left_panel, str): - self._left_sidebar.open_panel(left_panel) - elif not self._left_sidebar.is_open: - self._left_sidebar.open_panel() + from ui.main_window.session_restore import begin_session_restore + from ui.main_window.window import MainWindow - sidebar_panel = data.get("sidebar_panel") - if isinstance(sidebar_panel, str): - self._right_sidebar.open_panel(sidebar_panel) - sidebar_width = data.get("sidebar_width") - if isinstance(sidebar_width, int) and sidebar_width > 0: - self._right_sidebar._expand_flyout(sidebar_width) + begin_session_restore(cast(MainWindow, self)) def _restore_request_deferred(self, entry: dict, request_id: int) -> None: """Create a lightweight tab chip for a persisted request tab. @@ -907,6 +841,7 @@ def _materialise_deferred_tab(self, index: int) -> None: editor.request_changed.connect(lambda _: self._schedule_sidebar_snippet_refresh()) editor.scripts_tab_active_changed.connect(self._on_editor_scripts_tab_changed) viewer.save_response_requested.connect(self._on_save_response) + viewer.replay_history_link_clicked.connect(self._on_replay_history_link_clicked) viewer.save_availability_changed.connect(lambda _enabled: self._refresh_sidebar()) # Fetch breadcrumb once — reused by both the tab tooltip and @@ -1106,6 +1041,7 @@ def _on_tab_close(self, index: int) -> None: editor.dirty_changed.disconnect(self._on_editor_dirty_changed) editor.request_changed.disconnect() viewer.save_response_requested.disconnect(self._on_save_response) + viewer.replay_history_link_clicked.disconnect(self._on_replay_history_link_clicked) # Remove from stacked widgets and detach from parent hierarchy. self._editor_stack.removeWidget(editor) diff --git a/src/ui/main_window/variable_controller.py b/src/ui/main_window/variable_controller.py index 2cf9bd6..896f2e9 100644 --- a/src/ui/main_window/variable_controller.py +++ b/src/ui/main_window/variable_controller.py @@ -283,6 +283,11 @@ def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: can_save_current=ctx.response_viewer.has_live_response(), is_persisted_request=is_persisted_request, ) + self._right_sidebar.set_request_history_context( + request_id=ctx.request_id, + request_name=request_name, + is_persisted_request=is_persisted_request, + ) def _schedule_sidebar_snippet_refresh(self) -> None: """Debounce snippet refresh (300 ms) on request editor changes.""" diff --git a/src/ui/main_window/window.py b/src/ui/main_window/window.py index 51352b1..d618a5e 100644 --- a/src/ui/main_window/window.py +++ b/src/ui/main_window/window.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING, Any -from PySide6.QtCore import QEvent, QObject, QPoint, Qt, QThread, QTimer +from PySide6.QtCore import QEvent, QObject, QPoint, Qt, QThread, QTimer, Signal from PySide6.QtGui import QAction, QCloseEvent, QCursor, QGuiApplication, QKeySequence if TYPE_CHECKING: @@ -44,6 +44,7 @@ from ui.sidebar import LeftSidebar, RightSidebar from ui.sidebar.snippets_sidebar_panel import SnippetsSidebarPanel from ui.styling.icons import phi +from ui.styling.history_settings_manager import HistorySettingsManager from ui.styling.tab_settings_manager import TabSettingsManager from ui.styling.theme import COLOR_ACCENT, COLOR_TEXT_MUTED from ui.styling.theme_manager import ThemeManager @@ -69,16 +70,23 @@ class MainWindow( (Go menu, Ctrl+Alt+Left/Right), and cyclic tab deck (View, Ctrl+Tab). """ + session_restore_finished = Signal() + def __init__( self, theme_manager: ThemeManager | None = None, tab_settings_manager: TabSettingsManager | None = None, + history_settings_manager: HistorySettingsManager | None = None, ) -> None: """Initialise the main window, layout, and child widgets.""" super().__init__() self._theme_manager = theme_manager app = QApplication.instance() self._tab_settings_manager = tab_settings_manager or TabSettingsManager(app) + self._history_settings = history_settings_manager or HistorySettingsManager(self) + self._pending_request_snapshot = None + self._pending_history_context = None + self._suppress_history_record = False self.setWindowTitle("Postmark") # Pre-size to the available screen geometry so the window fills @@ -98,6 +106,7 @@ def __init__( self._tab_open_counter: int = 0 self._tab_activation_counter: int = 0 self._restoring_session: bool = False + self._session_restore_state = None # Per-tab state: tab-bar index -> TabContext self._tabs: dict[int, TabContext] = {} @@ -108,6 +117,8 @@ def __init__( # Legacy single-send state (used when no tab is found) self._send_thread: QThread | None = None self._send_worker: HttpSendWorker | None = None + self._local_project_thread: QThread | None = None + self._local_project_worker: QObject | None = None self._debug_protocol: DebugProtocol | None = None self._debug_script_host: Any | None = None @@ -116,7 +127,13 @@ def __init__( # Side rails (created before _setup_ui so layout can embed them) self._left_sidebar = LeftSidebar() - self._right_sidebar = RightSidebar() + from ui.sidebar.history.panel import HistoryPanel + + self._request_history_panel = HistoryPanel() + self._right_sidebar = RightSidebar(request_history_panel=self._request_history_panel) + self._request_history_panel.refresh_requested.connect(self._request_history_panel.refresh) + self._request_history_panel.replay_requested.connect(self._replay_request_history_entry) + self._request_history_panel.delete_requested.connect(self._delete_request_history_entry) if self._theme_manager is not None: self._theme_manager.theme_changed.connect(self._left_sidebar.refresh_theme) self._theme_manager.theme_changed.connect(self._right_sidebar.refresh_theme) @@ -208,6 +225,29 @@ def __init__( # ---- Move to the screen that contains the mouse -------------- self._move_to_mouse_screen() + self._start_local_project_config_sync() + + def _start_local_project_config_sync(self) -> None: + """Sync the Deno local-script mirror on a background thread (non-blocking startup).""" + from ui.main_window.startup_workers import LocalProjectConfigWorker + + thread = QThread(self) + worker = LocalProjectConfigWorker() + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._clear_local_project_sync_refs) + self._local_project_thread = thread + self._local_project_worker = worker + thread.start() + + def _clear_local_project_sync_refs(self) -> None: + """Drop thread/worker refs after background mirror sync completes.""" + self._local_project_thread = None + self._local_project_worker = None + def _move_to_mouse_screen(self) -> None: """Center the window on the monitor that the cursor is on.""" cursor_pos = QCursor.pos() @@ -417,6 +457,9 @@ def _build_response_area(self) -> QWidget: # Default response viewer self._default_response_viewer = ResponseViewerWidget() + self._default_response_viewer.replay_history_link_clicked.connect( + self._on_replay_history_link_clicked, + ) self.response_widget = self._default_response_viewer self._response_stack.addWidget(self._default_response_viewer) @@ -606,13 +649,13 @@ def _update_request_area_min(self) -> None: self._request_area.setMinimumHeight(bottom) def _on_load_finished(self) -> None: - """Switch from the loading screen to the main UI.""" + """Switch from the loading screen to the main UI and begin batched tab restore.""" self._loading_screen.stop_animation() self._main_stack.setCurrentIndex(1) self.menuBar().show() self.statusBar().show() - # Restore tabs from the previous session after collections are ready. + # Restore tabs incrementally so the event loop stays responsive. self._restore_tabs() def refresh_snippets_sidebar(self) -> None: @@ -697,6 +740,7 @@ def _open_settings_dialog(self, *, initial_category: str) -> None: self._tab_settings_manager, self, initial_category=initial_category, + history_settings_manager=self._history_settings, ) dialog.exec() w = self._editor_stack.currentWidget() diff --git a/src/ui/request/navigation/tab_manager.py b/src/ui/request/navigation/tab_manager.py index fd0cbc3..b4965ab 100644 --- a/src/ui/request/navigation/tab_manager.py +++ b/src/ui/request/navigation/tab_manager.py @@ -74,6 +74,8 @@ class TabContext: Used by policies such as "Close unused" and "Activate most recently used tab on close". nav_token: Stable id for tab activation back/forward stacks. + replay_source_entry_id: When set, the in-flight or last response is from + replaying this send-history row (for the response viewer banner). """ def __init__( @@ -124,6 +126,7 @@ def __init__( self.opened_order: int = opened_order self.last_activated_order: int = 0 self.nav_token: int = nav_token if nav_token is not None else allocate_tab_nav_token() + self.replay_source_entry_id: int | None = None def require_editor(self) -> RequestEditorWidget: """Return the request editor when this tab mounts one. diff --git a/src/ui/request/request_editor/scripts/script_editor_pane/pane.py b/src/ui/request/request_editor/scripts/script_editor_pane/pane.py index 1462006..4828cb3 100644 --- a/src/ui/request/request_editor/scripts/script_editor_pane/pane.py +++ b/src/ui/request/request_editor/scripts/script_editor_pane/pane.py @@ -22,6 +22,7 @@ QVBoxLayout, QWidget, ) +from shiboken6 import Shiboken from services.lsp.local_script_lsp_prep import ( ASYNC_LOCAL_LSP_PREP, @@ -661,8 +662,10 @@ def _schedule_refresh_script_split_full_width_line(self) -> None: def _refresh_script_split_full_width_line(self, *_args: object) -> None: """Show or hide the overlay and align it to the editor/output seam.""" + if not Shiboken.isValid(self): + return line = self._script_split_full_width_line - if line is None or not self.isVisible(): + if line is None or not Shiboken.isValid(line) or not self.isVisible(): return if self._splitter.count() < 2: line.hide() diff --git a/src/ui/request/request_editor/scripts/scripts_mixin.py b/src/ui/request/request_editor/scripts/scripts_mixin.py index 59eabb9..938ea11 100644 --- a/src/ui/request/request_editor/scripts/scripts_mixin.py +++ b/src/ui/request/request_editor/scripts/scripts_mixin.py @@ -8,6 +8,7 @@ from PySide6.QtCore import QPoint, QSettings, Qt, QTimer from PySide6.QtWidgets import QFrame, QSplitter, QToolButton, QVBoxLayout, QWidget +from shiboken6 import Shiboken from database.models.collections.collection_query_repository import get_script_chain from services.script_service import normalize_disabled_inherited @@ -346,10 +347,14 @@ def _wire_script_split_full_width_line(self) -> None: def _script_split_full_width_line_should_show(self) -> bool: """True when the Scripts section tab is active and editors exist.""" + if not Shiboken.isValid(self): + return False tabs = getattr(self, "_tabs", None) scripts_tab = getattr(self, "_scripts_tab", None) if tabs is None or scripts_tab is None: return False + if not Shiboken.isValid(tabs) or not Shiboken.isValid(scripts_tab): + return False if tabs.currentIndex() != tabs.indexOf(scripts_tab): return False if not getattr(self, "_scripts_editor_materialized", True): @@ -373,6 +378,8 @@ def _active_script_editor_output_splitter(self) -> QSplitter | None: def _refresh_script_split_full_width_line(self, *_args: object) -> None: """Show or hide the overlay and align it to the editor/output seam.""" + if not Shiboken.isValid(self): + return line = getattr(self, "_script_split_full_width_line", None) if line is None: return diff --git a/src/ui/request/response_viewer/replay_indicator.py b/src/ui/request/response_viewer/replay_indicator.py new file mode 100644 index 0000000..bac176c --- /dev/null +++ b/src/ui/request/response_viewer/replay_indicator.py @@ -0,0 +1,61 @@ +"""Replayed-send banner shown in the response viewer status corner.""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel + +from ui.styling.icons import phi +from ui.styling.theme import COLOR_ACCENT +from ui.widgets.info_popup import ClickableLabel + + +class ResponseReplayIndicator(QFrame): + """Compact pill: replay icon, label, and link to the source history row.""" + + link_clicked = Signal(int) + + def __init__(self, parent: QFrame | None = None) -> None: + """Build the indicator (hidden until a replay source is set).""" + super().__init__(parent) + self.setObjectName("responseReplayIndicator") + self._entry_id: int | None = None + + row = QHBoxLayout(self) + row.setContentsMargins(8, 3, 10, 3) + row.setSpacing(6) + + icon = QLabel() + icon.setPixmap(phi("arrow-clockwise", color=COLOR_ACCENT).pixmap(14, 14)) + icon.setFixedSize(14, 14) + row.addWidget(icon) + + prefix = QLabel("Replayed request") + prefix.setObjectName("responseReplayPrefix") + row.addWidget(prefix) + + self._link = ClickableLabel("View in history") + self._link.setObjectName("responseReplayLink") + self._link.setToolTip("Open History and select this send") + self._link.clicked.connect(self._on_link_clicked) + row.addWidget(self._link) + + self.hide() + + def set_source(self, entry_id: int, link_text: str) -> None: + """Show the indicator for history row *entry_id*.""" + self._entry_id = entry_id + self._link.setText(link_text) + self.show() + + def clear_source(self) -> None: + """Hide the indicator.""" + self._entry_id = None + self.hide() + + def _on_link_clicked(self) -> None: + if self._entry_id is not None: + self.link_clicked.emit(self._entry_id) + + +__all__ = ["ResponseReplayIndicator"] diff --git a/src/ui/request/response_viewer/viewer_widget.py b/src/ui/request/response_viewer/viewer_widget.py index 3626536..64faebc 100644 --- a/src/ui/request/response_viewer/viewer_widget.py +++ b/src/ui/request/response_viewer/viewer_widget.py @@ -35,6 +35,7 @@ ) from ui.request.response_viewer.popup_mixin import _PopupMixin +from ui.request.response_viewer.replay_indicator import ResponseReplayIndicator from ui.request.response_viewer.pre_request_mixin import _PreRequestMixin from ui.request.response_viewer.search_filter import _SearchFilterMixin from ui.request.response_viewer.test_results_mixin import _TestResultsMixin @@ -97,6 +98,7 @@ class ResponseViewerWidget( save_response_requested = Signal(dict) save_availability_changed = Signal(bool) + replay_history_link_clicked = Signal(int) def __init__(self, parent: QWidget | None = None) -> None: """Initialise the response viewer layout.""" @@ -116,6 +118,12 @@ def __init__(self, parent: QWidget | None = None) -> None: status_row.setSpacing(12) status_row.setContentsMargins(0, 0, 0, 0) + self._replay_indicator = ResponseReplayIndicator() + self._replay_indicator.link_clicked.connect(self.replay_history_link_clicked.emit) + status_row.addWidget(self._replay_indicator) + + status_row.addStretch(1) + self._status_label = ClickableLabel() self._status_label.setStyleSheet("font-weight: bold; padding: 2px 8px; border-radius: 3px;") self._status_label.clicked.connect(self._on_status_clicked) @@ -366,8 +374,17 @@ def _toggle_filter(self) -> None: return super()._toggle_filter() + def set_replay_history_source(self, entry_id: int, link_text: str) -> None: + """Show the replay banner pointing at send-history row *entry_id*.""" + self._replay_indicator.set_source(entry_id, link_text) + + def clear_replay_history_source(self) -> None: + """Hide the replay banner.""" + self._replay_indicator.clear_source() + def show_loading(self) -> None: """Display the indeterminate progress bar (request in flight).""" + self._replay_indicator.hide() self._set_state("loading") def show_error(self, message: str) -> None: diff --git a/src/ui/sidebar/history/__init__.py b/src/ui/sidebar/history/__init__.py new file mode 100644 index 0000000..fea60c7 --- /dev/null +++ b/src/ui/sidebar/history/__init__.py @@ -0,0 +1,7 @@ +"""Send-history sidebar panel package.""" + +from __future__ import annotations + +from ui.sidebar.history.panel import HistoryPanel + +__all__ = ["HistoryPanel"] diff --git a/src/ui/sidebar/history/delegate.py b/src/ui/sidebar/history/delegate.py new file mode 100644 index 0000000..db9f829 --- /dev/null +++ b/src/ui/sidebar/history/delegate.py @@ -0,0 +1,159 @@ +"""Custom delegate for the send-history tree widget.""" + +from __future__ import annotations + +from PySide6.QtCore import QModelIndex, QPersistentModelIndex, QRect, QSize, Qt +from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter, QPen +from PySide6.QtWidgets import QStyle, QStyledItemDelegate, QStyleOptionViewItem + +from ui.styling.theme import ( + BADGE_BORDER_RADIUS, + BADGE_FONT_SIZE, + COLOR_TEXT, + COLOR_TEXT_MUTED, + status_color, +) + +ROLE_HISTORY_CODE = Qt.ItemDataRole.UserRole + 1 +ROLE_HISTORY_NAME = Qt.ItemDataRole.UserRole + 2 +ROLE_HISTORY_META = Qt.ItemDataRole.UserRole + 3 +ROLE_HISTORY_IS_DATE_GROUP = Qt.ItemDataRole.UserRole + 4 + +_BADGE_WIDTH = 36 +_BADGE_HEIGHT = 16 +_BADGE_NAME_SPACING = 6 +_LEFT_PADDING = 6 +_TOP_PADDING = 6 +_LINE_SPACING = 2 +_ROW_HEIGHT = 44 +_DATE_GROUP_HEIGHT = 28 + + +class HistoryEntryDelegate(QStyledItemDelegate): + """Paint date group rows like collection folders and sends with status badges.""" + + def paint( + self, + painter: QPainter, + option: QStyleOptionViewItem, + index: QModelIndex | QPersistentModelIndex, + ) -> None: + """Paint a date group or a send-history row.""" + if index.data(ROLE_HISTORY_IS_DATE_GROUP): + self._paint_date_group(painter, option, index) + return + + 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_HISTORY_CODE) + name = index.data(ROLE_HISTORY_NAME) or "" + meta = index.data(ROLE_HISTORY_META) or "" + rect: QRect = option.rect # type: ignore[assignment] + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + 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 if code is not None else 0)) + 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) + + 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) + + text_color = QColor(COLOR_TEXT) + + 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) + + 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) + painter.setPen(QPen(QColor(COLOR_TEXT_MUTED))) + 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 _paint_date_group( + self, + painter: QPainter, + option: QStyleOptionViewItem, + index: QModelIndex | QPersistentModelIndex, + ) -> None: + """Paint a date group row using standard tree branch + bold label.""" + self.initStyleOption(option, index) + opt = QStyleOptionViewItem(option) + label = str(index.data(ROLE_HISTORY_NAME) or index.data(Qt.ItemDataRole.DisplayRole) or "") + opt.text = label + font = QFont(opt.font) + font.setPixelSize(12) + font.setBold(True) + opt.font = font + style = option.widget.style() if option.widget else None + if style: + style.drawControl( + QStyle.ControlElement.CE_ItemViewItem, + opt, + painter, + option.widget, + ) + + def sizeHint( + self, + option: QStyleOptionViewItem, + index: QModelIndex | QPersistentModelIndex, + ) -> QSize: + """Return a fixed row height for date groups and send rows.""" + if index.data(ROLE_HISTORY_IS_DATE_GROUP): + return QSize(option.rect.width(), _DATE_GROUP_HEIGHT) + return QSize(option.rect.width(), _ROW_HEIGHT) diff --git a/src/ui/sidebar/history/helpers.py b/src/ui/sidebar/history/helpers.py new file mode 100644 index 0000000..b505105 --- /dev/null +++ b/src/ui/sidebar/history/helpers.py @@ -0,0 +1,170 @@ +"""Formatting helpers for send-history sidebar rows and detail panes.""" + +from __future__ import annotations + +from collections import OrderedDict +from collections.abc import Iterator, Mapping, Sequence +from datetime import datetime, timedelta +from typing import Any + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem + +from ui.sidebar.history.delegate import ( + ROLE_HISTORY_CODE, + ROLE_HISTORY_IS_DATE_GROUP, + ROLE_HISTORY_META, + ROLE_HISTORY_NAME, +) +from ui.sidebar.saved_responses.helpers import ( + extract_snapshot_headers, + format_body_size, + format_headers, +) + + +def format_executed_at(iso_value: str) -> str: + """Format an ISO ``executed_at`` timestamp for list metadata.""" + text = iso_value.strip() + if not text: + return "" + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + local = parsed.astimezone() + return local.strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + return text[:19].replace("T", " ") + + +def local_date_group_label(iso_value: str) -> str: + """Return a human-readable group heading for an ISO ``executed_at`` value.""" + text = iso_value.strip() + if not text: + return "Unknown date" + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + local_day = parsed.astimezone().date() + except ValueError: + return text[:10] if len(text) >= 10 else "Unknown date" + today = datetime.now().astimezone().date() + if local_day == today: + return "Today" + if local_day == today - timedelta(days=1): + return "Yesterday" + return local_day.strftime("%A, %B %d, %Y") + + +def group_entries_by_local_date( + items: Sequence[Mapping[str, Any]], +) -> list[tuple[str, list[Mapping[str, Any]]]]: + """Group history rows by local calendar day (preserves *items* order within each day).""" + groups: OrderedDict[str, list[Mapping[str, Any]]] = OrderedDict() + for item in items: + executed = str(item.get("executed_at", "")) + label = local_date_group_label(executed) + groups.setdefault(label, []).append(item) + return list(groups.items()) + + +def iter_history_tree_items(tree: QTreeWidget) -> Iterator[QTreeWidgetItem]: + """Yield every item in *tree* (depth-first).""" + + def visit(item: QTreeWidgetItem) -> Iterator[QTreeWidgetItem]: + yield item + for index in range(item.childCount()): + yield from visit(item.child(index)) + + for index in range(tree.topLevelItemCount()): + top = tree.topLevelItem(index) + if top is not None: + yield from visit(top) + + +def populate_history_tree_widget( + tree: QTreeWidget, + items: Sequence[Mapping[str, Any]], +) -> None: + """Fill *tree* with date group parents and send-history child rows.""" + tree.clear() + if not items: + return + for day_label, day_items in group_entries_by_local_date(items): + group = QTreeWidgetItem([day_label]) + group.setData(0, ROLE_HISTORY_IS_DATE_GROUP, True) + group.setData(0, ROLE_HISTORY_NAME, day_label) + group.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) + group.setChildIndicatorPolicy(QTreeWidgetItem.ChildIndicatorPolicy.ShowIndicator) + tree.addTopLevelItem(group) + for item in day_items: + entry_id = int(item["id"]) + row = QTreeWidgetItem(group) + row.setData(0, Qt.ItemDataRole.UserRole, entry_id) + row.setData(0, ROLE_HISTORY_CODE, item.get("status_code")) + row.setData(0, ROLE_HISTORY_NAME, build_row_name(item)) + row.setData(0, ROLE_HISTORY_META, build_history_row_meta(item)) + row.setToolTip(0, build_row_name(item)) + group.setExpanded(True) + tree.expandAll() + + +def first_history_entry_id(tree: QTreeWidget) -> int | None: + """Return the first send entry id in *tree*, or ``None``.""" + for item in iter_history_tree_items(tree): + if item.data(0, ROLE_HISTORY_IS_DATE_GROUP): + continue + entry_id = item.data(0, Qt.ItemDataRole.UserRole) + if isinstance(entry_id, int): + return entry_id + return None + + +def find_history_tree_item(tree: QTreeWidget, entry_id: int) -> QTreeWidgetItem | None: + """Return the tree item for *entry_id*, or ``None`` when not present.""" + for item in iter_history_tree_items(tree): + if item.data(0, ROLE_HISTORY_IS_DATE_GROUP): + continue + if item.data(0, Qt.ItemDataRole.UserRole) == entry_id: + return item + return None + + +def build_row_name(entry: Mapping[str, Any]) -> str: + """Return the primary list label for a history row.""" + name = str(entry.get("request_name", "")).strip() + if name: + return name + method = str(entry.get("method", "GET")) + url = str(entry.get("url", "")) + combined = f"{method} {url}".strip() + return combined[:120] if combined else "Send" + + +def extract_history_request_headers(snapshot: Mapping[str, Any] | None) -> str: + """Return request header text as sent (editor rows + auth-injected headers).""" + if not snapshot: + return "" + sent = snapshot.get("sent_headers") + if sent: + return format_headers(sent) + return extract_snapshot_headers(snapshot) + + +def build_history_row_meta(entry: Mapping[str, Any]) -> str: + """Return a metadata summary line for a send-history list row.""" + parts: list[str] = [] + method = str(entry.get("method", "")).strip() + if method: + parts.append(method) + status = entry.get("status_code") + if status is not None: + parts.append(str(status)) + executed = entry.get("executed_at") + if isinstance(executed, str) and executed: + parts.append(format_executed_at(executed)) + size = entry.get("response_size_bytes") + if size: + parts.append(format_body_size(int(size))) + label = entry.get("source_label") + if label: + parts.append(str(label)) + return " \u00b7 ".join(parts) diff --git a/src/ui/sidebar/history/panel.py b/src/ui/sidebar/history/panel.py new file mode 100644 index 0000000..7cace1c --- /dev/null +++ b/src/ui/sidebar/history/panel.py @@ -0,0 +1,672 @@ +"""Send-history panel — per-request list/detail flyout (read-only).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from PySide6.QtCore import QPoint, Qt, Signal +from PySide6.QtGui import QAction +from PySide6.QtWidgets import ( + QAbstractItemView, + QApplication, + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMenu, + QPushButton, + QFrame, + QStackedWidget, + QTreeWidget, + QTreeWidgetItem, + QSplitter, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from services.request_history_service import RequestHistoryService +from ui.sidebar.history.delegate import ( + ROLE_HISTORY_IS_DATE_GROUP, + HistoryEntryDelegate, +) +from ui.sidebar.history.helpers import ( + build_row_name, + extract_history_request_headers, + find_history_tree_item, + first_history_entry_id, + format_executed_at, + populate_history_tree_widget, +) +from ui.sidebar.history.panel_detail_tabs import _HistoryPanelDetailTabsMixin +from ui.sidebar.history.search_filter import _PanelSearchFilterMixin +from ui.sidebar.saved_responses.helpers import ( + detect_body_language, + extract_snapshot_body, + extract_snapshot_method, + extract_snapshot_url, + format_body_size, + format_code_text, + format_headers, +) +from ui.styling.icons import phi +from ui.styling.theme import COLOR_WHITE, method_color, status_color +from ui.widgets.code_editor import CodeEditorWidget + +if TYPE_CHECKING: + from services.request_history_service import RequestHistoryEntryDict + + +class HistoryPanel(_HistoryPanelDetailTabsMixin, _PanelSearchFilterMixin, QWidget): + """Read-only list/detail panel for HTTP send history on a request tab.""" + + refresh_requested = Signal() + replay_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.setObjectName("requestHistoryPanel") + + self._request_id: int | None = None + self._request_name: str = "" + self._is_persisted_request: bool = False + self._items: list[RequestHistoryEntryDict] = [] + self._items_by_id: dict[int, RequestHistoryEntryDict] = {} + self._current_entry_id: int | None = None + self._body_raw_text: str = "" + self._body_language: str = "text" + self._snapshot_raw_data: Any = None + self._req_body_raw_text: str = "" + self._req_body_language: str = "text" + self._body_view_mode: str = "Pretty" + self._req_body_view_mode: str = "Pretty" + + root = QVBoxLayout(self) + root.setContentsMargins(8, 4, 8, 8) + root.setSpacing(6) + + self._refresh_btn = self._make_icon_btn( + "arrow-clockwise", + "Refresh history", + "iconButton", + self.refresh_requested.emit, + ) + + 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._history_search_input = QLineEdit() + self._history_search_input.setObjectName("requestHistorySearch") + self._history_search_input.setPlaceholderText("Search URL or status (e.g. 200, 400)") + self._history_search_input.setClearButtonEnabled(True) + self._history_search_input.textChanged.connect(self._on_history_search_changed) + self._history_search_input.hide() + root.addWidget(self._history_search_input) + + self._content_splitter = QSplitter(Qt.Orientation.Vertical) + self._content_splitter.setChildrenCollapsible(False) + self._content_splitter.hide() + root.addWidget(self._content_splitter, 1) + + self._list_stack = QStackedWidget() + self._list_stack.setObjectName("requestHistoryList") + + no_match_page = QFrame() + no_match_page.setObjectName("requestHistoryListEmpty") + no_match_layout = QVBoxLayout(no_match_page) + no_match_layout.setContentsMargins(8, 8, 8, 8) + self._list_empty_label = QLabel() + self._list_empty_label.setObjectName("emptyStateLabel") + self._list_empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._list_empty_label.setWordWrap(True) + no_match_layout.addStretch(1) + no_match_layout.addWidget(self._list_empty_label) + no_match_layout.addStretch(1) + self._list_stack.addWidget(no_match_page) + + self._tree_widget = QTreeWidget() + self._tree_widget.setObjectName("requestHistoryTree") + self._tree_widget.setHeaderHidden(True) + self._tree_widget.setRootIsDecorated(True) + self._tree_widget.setIndentation(16) + self._tree_widget.setAnimated(False) + self._tree_widget.setExpandsOnDoubleClick(False) + self._tree_widget.setCursor(Qt.CursorShape.PointingHandCursor) + self._tree_widget.itemClicked.connect(self._on_tree_item_clicked) + self._tree_widget.currentItemChanged.connect(self._on_selection_changed) + self._tree_widget.setItemDelegate(HistoryEntryDelegate(self._tree_widget)) + self._tree_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._tree_widget.customContextMenuRequested.connect(self._on_tree_context_menu) + self._list_stack.addWidget(self._tree_widget) + self._list_stack.setCurrentWidget(self._tree_widget) + + self._content_splitter.addWidget(self._list_stack) + + 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 send") + 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._replay_btn = self._make_replay_btn() + detail_header.addWidget(self._replay_btn) + + detail_layout.addLayout(detail_header) + + request_info_row = QHBoxLayout() + request_info_row.setContentsMargins(0, 0, 0, 0) + request_info_row.setSpacing(6) + + self._request_method_badge = QLabel() + self._request_method_badge.setObjectName("savedResponseMethodBadge") + self._request_method_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._request_method_badge.setFixedHeight(20) + self._request_method_badge.setFixedWidth(50) + request_info_row.addWidget(self._request_method_badge) + + self._request_url_label = QLabel() + self._request_url_label.setObjectName("mutedLabel") + self._request_url_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + request_info_row.addWidget(self._request_url_label, 1) + + self._request_info_widget = QWidget() + request_info_layout = QVBoxLayout(self._request_info_widget) + request_info_layout.setContentsMargins(0, 0, 0, 0) + request_info_layout.addLayout(request_info_row) + self._request_info_widget.hide() + detail_layout.addWidget(self._request_info_widget) + + 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_request_headers_tab() + self._build_request_body_tab() + + self._content_splitter.addWidget(detail_host) + self._content_splitter.setSizes([180, 280]) + + self.clear() + + def refresh_button(self) -> QPushButton: + """Return the refresh control (reparented to the flyout title bar).""" + return self._refresh_btn + + def _on_tree_context_menu(self, pos: QPoint) -> None: + """Show replay/delete actions for send rows under the cursor.""" + item = self._tree_widget.itemAt(pos) + if item is None or item.data(0, ROLE_HISTORY_IS_DATE_GROUP): + return + entry_id = item.data(0, Qt.ItemDataRole.UserRole) + if not isinstance(entry_id, int): + return + + menu = QMenu(self) + replay_action = QAction("Replay this request", self) + replay_action.triggered.connect(lambda: self.replay_requested.emit(entry_id)) + delete_action = QAction("Delete item", self) + delete_action.triggered.connect(lambda: self.delete_requested.emit(entry_id)) + menu.addAction(replay_action) + menu.addAction(delete_action) + menu.exec(self._tree_widget.viewport().mapToGlobal(pos)) + + def _on_replay_clicked(self) -> None: + """Emit :attr:`replay_requested` for the selected history row.""" + if self._current_entry_id is not None: + self.replay_requested.emit(self._current_entry_id) + + def set_request_context( + self, + request_id: int | None, + request_name: str | None, + *, + is_persisted_request: bool, + ) -> None: + """Set the active request context shown in the panel header.""" + if request_id != self._request_id: + self._current_entry_id = None + self._request_id = request_id + self._request_name = request_name or "" + self._is_persisted_request = is_persisted_request + + def _show_full_panel_empty_state(self, message: str) -> None: + """Hide browse chrome and show a centred empty message (no search box).""" + self._state_label.setText(message) + self._state_label.show() + self._history_search_input.hide() + self._content_splitter.hide() + self._set_detail_enabled(False) + + def _show_browse_layout(self) -> None: + """Show search, list, and detail (hide full-panel empty message).""" + self._state_label.hide() + self._history_search_input.show() + self._content_splitter.show() + + def show_request_required_state(self, message: str) -> None: + """Show a contextual empty state when history is unavailable.""" + self._show_full_panel_empty_state(message) + self._refresh_btn.setEnabled(False) + + def show_empty_history_state(self) -> None: + """Show the empty state for a persisted request with no sends yet.""" + self._show_full_panel_empty_state( + "No history for this request yet.\n\nSend the request to record an entry here." + ) + self._refresh_btn.setEnabled(self._request_id is not None) + + def refresh(self, search: str = "") -> None: + """Reload send history for the current persisted request.""" + if not self._is_persisted_request or self._request_id is None: + return + term = search if search else self._history_search_input.text().strip() + items = RequestHistoryService.list_for_request(self._request_id, search=term) + self._apply_items(items) + + def _on_history_search_changed(self, text: str) -> None: + """Filter the list when the search box changes.""" + if not self._is_persisted_request or self._request_id is None: + return + self.refresh(search=text.strip()) + + def _apply_items(self, items: list[RequestHistoryEntryDict]) -> None: + """Populate the list from metadata rows.""" + self._items = items + self._items_by_id = {int(item["id"]): item for item in items if "id" in item} + self._refresh_btn.setEnabled(self._request_id is not None) + self._history_search_input.setEnabled(True) + + if not items: + self._current_entry_id = None + self._tree_widget.clear() + term = self._history_search_input.text().strip() + if term: + self._show_browse_layout() + self._list_empty_label.setText(f'No history matches "{term}".') + self._list_stack.setCurrentIndex(0) + self._set_detail_enabled(False) + else: + self.show_empty_history_state() + return + + populate_history_tree_widget(self._tree_widget, items) + + self._show_browse_layout() + self._list_stack.setCurrentWidget(self._tree_widget) + first_id = first_history_entry_id(self._tree_widget) + target_id = self._current_entry_id if self._current_entry_id in self._items_by_id else None + self._select_entry(target_id or first_id) + + def clear(self) -> None: + """Reset the panel to its no-request state.""" + self._request_id = None + self._request_name = "" + self._is_persisted_request = False + self._items = [] + self._items_by_id = {} + self._current_entry_id = None + self._tree_widget.clear() + self._history_search_input.clear() + self._history_search_input.setEnabled(False) + self._body_raw_text = "" + self._body_language = "text" + self._snapshot_raw_data = None + self._req_body_raw_text = "" + self._req_body_language = "text" + self._body_view_mode = "Pretty" + self._req_body_view_mode = "Pretty" + self._set_combo_text(self._body_view_combo, self._body_view_mode) + self._set_combo_text(self._req_body_view_combo, self._req_body_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._req_headers_edit.set_language("text") + self._req_headers_edit.set_text("") + self._req_headers_edit.hide() + self._req_headers_empty_label.show() + self._req_body_edit.set_language("text") + self._req_body_edit.set_text("") + self._req_body_edit.hide() + self._req_body_empty_label.show() + self._request_info_widget.hide() + self._status_badge.setText("") + self._status_badge.setStyleSheet("") + self._detail_name.setText("Select a send") + self._detail_meta.setText("") + self._reset_search_filter() + self.show_request_required_state( + "Open a saved request to browse history for this request." + ) + + def _restore_tree_selection_to_current_entry(self) -> bool: + """Re-select the active send row after a date-group row receives focus.""" + if self._current_entry_id is None: + return False + leaf = find_history_tree_item(self._tree_widget, self._current_entry_id) + if leaf is None: + return False + self._tree_widget.blockSignals(True) + self._tree_widget.setCurrentItem(leaf) + self._tree_widget.blockSignals(False) + return True + + def _on_tree_item_clicked(self, item: QTreeWidgetItem, _column: int) -> None: + """Toggle date groups on single click without changing the active send.""" + if not item.data(0, ROLE_HISTORY_IS_DATE_GROUP): + return + item.setExpanded(not item.isExpanded()) + self._restore_tree_selection_to_current_entry() + + def focus_entry(self, entry_id: int) -> bool: + """Select *entry_id* in the tree, expand its date group, and load detail.""" + if not self._is_persisted_request or self._request_id is None: + return False + if entry_id not in self._items_by_id: + self.refresh() + item = find_history_tree_item(self._tree_widget, entry_id) + if item is None: + return False + parent = item.parent() + if parent is not None: + parent.setExpanded(True) + self._tree_widget.scrollToItem( + item, + QAbstractItemView.ScrollHint.EnsureVisible, + ) + self._select_entry(entry_id) + return True + + def _select_entry(self, entry_id: int | None) -> None: + """Select a tree row and load detail for *entry_id*.""" + if entry_id is None: + self._set_detail_enabled(False) + return + item = find_history_tree_item(self._tree_widget, entry_id) + if item is not None: + self._tree_widget.setCurrentItem(item) + self._load_detail(entry_id) + return + first_id = first_history_entry_id(self._tree_widget) + if first_id is not None: + self._select_entry(first_id) + return + self._set_detail_enabled(False) + + def _on_selection_changed( + self, + current: QTreeWidgetItem | None, + _previous: QTreeWidgetItem | None, + ) -> None: + """Update the detail pane when the tree selection changes.""" + if current is not None and current.data(0, ROLE_HISTORY_IS_DATE_GROUP): + self._restore_tree_selection_to_current_entry() + return + if current is None: + if self._restore_tree_selection_to_current_entry(): + return + self._set_detail_enabled(False) + return + entry_id = current.data(0, Qt.ItemDataRole.UserRole) + if not isinstance(entry_id, int): + self._set_detail_enabled(False) + return + self._current_entry_id = entry_id + self._load_detail(entry_id) + + def _load_detail(self, entry_id: int) -> None: + """Fetch full entry payloads and render the detail pane.""" + entry = RequestHistoryService.get_entry(entry_id) + if entry is None: + self._set_detail_enabled(False) + return + detail = RequestHistoryService.entry_to_detail_snapshot(entry) + self._populate_detail(entry, detail) + + def _populate_detail( + self, + entry: RequestHistoryEntryDict, + detail: dict[str, Any], + ) -> None: + """Render one send-history entry in the detail pane.""" + code = detail.get("status_code", 0) + badge_text = str(code) if code else "\u2014" + colour = status_color(code if code else 0) + 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;" + ) + + self._detail_name.setText(build_row_name(entry)) + meta_parts: list[str] = [] + executed = entry.get("executed_at") + if isinstance(executed, str) and executed: + meta_parts.append(format_executed_at(executed)) + elapsed = entry.get("elapsed_ms") + if elapsed is not None: + meta_parts.append(f"{float(elapsed):.0f} ms") + size = entry.get("response_size_bytes") + if size: + meta_parts.append(format_body_size(int(size))) + label = entry.get("source_label") + if label: + meta_parts.append(str(label)) + if detail.get("body_truncated"): + meta_parts.append("truncated") + if detail.get("error"): + meta_parts.append(str(detail["error"])) + self._detail_meta.setText(" \u00b7 ".join(meta_parts)) + + self._reset_search_filter() + body_text = str(detail.get("body") or "") + self._body_raw_text = body_text + self._body_language = detect_body_language(body_text) or "text" + self._set_combo_text(self._body_view_combo, self._body_view_mode) + self._refresh_body_view() + + headers_text = format_headers(detail.get("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() + + snapshot = detail.get("original_request") + self._snapshot_raw_data = snapshot + req_method = extract_snapshot_method(snapshot if isinstance(snapshot, dict) else None) + req_url = extract_snapshot_url(snapshot if isinstance(snapshot, dict) else None) + if req_method or req_url: + self._request_method_badge.setText(req_method) + method_colour = method_color(req_method) + self._request_method_badge.setStyleSheet( + f"background: {method_colour}; color: #ffffff; " + f"padding: 1px 6px; border-radius: 3px; " + f"font-weight: bold; font-size: 10px;" + ) + self._request_url_label.setText(req_url) + self._request_url_label.setToolTip(req_url) + self._request_info_widget.show() + else: + self._request_info_widget.hide() + + req_headers_text = extract_history_request_headers( + snapshot if isinstance(snapshot, dict) else None + ) + if req_headers_text: + self._req_headers_empty_label.hide() + self._req_headers_edit.show() + self._req_headers_edit.set_language("text") + self._req_headers_edit.set_text(req_headers_text) + else: + self._req_headers_edit.hide() + self._req_headers_empty_label.show() + + req_body, req_body_lang = extract_snapshot_body( + snapshot if isinstance(snapshot, dict) else None + ) + self._req_body_raw_text = req_body + self._req_body_language = req_body_lang + self._set_combo_text(self._req_body_view_combo, self._req_body_view_mode) + self._refresh_request_body_view() + + self._set_detail_enabled(True) + self._update_replay_button_enabled() + + def _update_replay_button_enabled(self) -> None: + """Enable replay when the selected row has a URL to send.""" + btn = getattr(self, "_replay_btn", None) + if btn is None: + return + entry = ( + self._items_by_id.get(self._current_entry_id) + if self._current_entry_id is not None + else None + ) + if entry is None: + btn.setEnabled(False) + return + btn.setEnabled(RequestHistoryService.can_replay_entry(entry)) + + def _set_detail_enabled(self, enabled: bool) -> None: + """Enable or disable detail tabs.""" + self._detail_tabs.setEnabled(enabled) + if not enabled: + replay = getattr(self, "_replay_btn", None) + if replay is not None: + replay.setEnabled(False) + + def _make_replay_btn(self) -> QPushButton: + """White replay icon in the detail header.""" + btn = QPushButton() + btn.setIcon(phi("arrow-clockwise", color=COLOR_WHITE, size=16)) + btn.setObjectName("requestHistoryReplayButton") + btn.setFixedSize(28, 28) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setToolTip("Replay this request (updates response only)") + btn.setEnabled(False) + btn.clicked.connect(self._on_replay_clicked) + return btn + + 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 _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 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) + + 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_request_body_view(self, _mode: str | None = None) -> None: + """Render the request snapshot body using the selected view mode.""" + self._req_body_view_mode = self._req_body_view_combo.currentText() + if not self._req_body_raw_text: + self._req_body_edit.hide() + self._req_body_empty_label.show() + return + + self._req_body_empty_label.hide() + self._req_body_edit.show() + language = self._req_body_language or "text" + body_text = self._req_body_raw_text + if self._req_body_view_mode == "Pretty": + body_text = format_code_text(body_text, language, pretty=True) + self._req_body_edit.set_language(language) + self._req_body_edit.set_text(body_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/history/panel_detail_tabs.py b/src/ui/sidebar/history/panel_detail_tabs.py new file mode 100644 index 0000000..52e7196 --- /dev/null +++ b/src/ui/sidebar/history/panel_detail_tabs.py @@ -0,0 +1,96 @@ +"""Read-only detail tabs for :class:`HistoryPanel`.""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QPushButton, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from ui.widgets.code_editor import CodeEditorWidget + + +class _HistoryPanelDetailTabsMixin: + """Build Headers / Request Headers / Request Body tabs on ``_detail_tabs``.""" + + _detail_tabs: QTabWidget + + def _refresh_request_body_view(self, _mode: str | None = None) -> None: ... + + def _make_copy_btn(self, slot: object) -> QPushButton: + return QPushButton() + + def _copy_editor(self, editor: CodeEditorWidget) -> None: ... + + def _make_empty_label(self, text: str) -> QLabel: + return QLabel() + + _headers_edit: CodeEditorWidget + _headers_empty_label: QLabel + _req_headers_edit: CodeEditorWidget + _req_headers_empty_label: QLabel + _req_body_edit: CodeEditorWidget + _req_body_empty_label: QLabel + _req_body_view_combo: QComboBox + + def _build_headers_tab(self) -> None: + """Construct the Headers tab.""" + editor, empty, _ = self._build_readonly_tab("Headers", "No response headers") + self._headers_edit = editor + self._headers_empty_label = empty + + def _build_request_headers_tab(self) -> None: + """Construct the Request Headers tab.""" + editor, empty, _ = self._build_readonly_tab("Request Headers", "No request headers") + self._req_headers_edit = editor + self._req_headers_empty_label = empty + + def _build_request_body_tab(self) -> None: + """Construct the Request Body tab with Pretty/Raw combo.""" + editor, empty, combo = self._build_readonly_tab( + "Request Body", + "No request body", + view_combo=True, + ) + self._req_body_edit = editor + self._req_body_empty_label = empty + assert combo is not None + self._req_body_view_combo = combo + self._req_body_view_combo.currentTextChanged.connect(self._refresh_request_body_view) + + def _build_readonly_tab( + self, + title: str, + empty_text: str, + *, + view_combo: bool = False, + ) -> tuple[CodeEditorWidget, QLabel, QComboBox | None]: + """Build a read-only tab with optional Pretty/Raw combo.""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + toolbar = QHBoxLayout() + toolbar.setContentsMargins(0, 0, 0, 0) + toolbar.setSpacing(6) + combo: QComboBox | None = None + if view_combo: + combo = QComboBox() + combo.addItems(["Pretty", "Raw"]) + combo.setFixedWidth(90) + toolbar.addWidget(combo) + toolbar.addStretch() + editor = CodeEditorWidget(read_only=True) + copy_btn = self._make_copy_btn(lambda e=editor: self._copy_editor(e)) + toolbar.addWidget(copy_btn) + layout.addLayout(toolbar) + empty_label = self._make_empty_label(empty_text) + layout.addWidget(empty_label, 1) + layout.addWidget(editor, 1) + self._detail_tabs.addTab(tab, title) + return editor, empty_label, combo diff --git a/src/ui/sidebar/history/search_filter.py b/src/ui/sidebar/history/search_filter.py new file mode 100644 index 0000000..a398247 --- /dev/null +++ b/src/ui/sidebar/history/search_filter.py @@ -0,0 +1,7 @@ +"""Body search/filter mixin for send-history panel (shared implementation).""" + +from __future__ import annotations + +from ui.sidebar.saved_responses.search_filter import _PanelSearchFilterMixin + +__all__ = ["_PanelSearchFilterMixin"] diff --git a/src/ui/sidebar/saved_responses/search_filter.py b/src/ui/sidebar/saved_responses/search_filter.py index f52b513..9426bda 100644 --- a/src/ui/sidebar/saved_responses/search_filter.py +++ b/src/ui/sidebar/saved_responses/search_filter.py @@ -90,6 +90,9 @@ def _make_empty_label(text: str) -> QLabel: def _copy_editor(self, editor: CodeEditorWidget) -> None: ... + def _body_toolbar_extras(self, toolbar: QHBoxLayout) -> None: + """Add panel-specific controls after Pretty/Raw (override in hosts).""" + # -- Body tab construction ----------------------------------------- def _build_body_tab(self) -> None: @@ -106,6 +109,7 @@ def _build_body_tab(self) -> None: self._body_view_combo.setFixedWidth(90) self._body_view_combo.currentTextChanged.connect(self._refresh_body_view) body_toolbar.addWidget(self._body_view_combo) + self._body_toolbar_extras(body_toolbar) body_toolbar.addStretch() self._body_edit = CodeEditorWidget(read_only=True) self._body_copy_btn = self._make_icon_btn( diff --git a/src/ui/sidebar/sidebar_widget.py b/src/ui/sidebar/sidebar_widget.py index a166fd7..057465d 100644 --- a/src/ui/sidebar/sidebar_widget.py +++ b/src/ui/sidebar/sidebar_widget.py @@ -30,6 +30,7 @@ ) from services.collection_service import SavedResponseDict +from ui.sidebar.history.panel import HistoryPanel from ui.sidebar.saved_responses.panel import SavedResponsesPanel from ui.sidebar.snippet_panel import SnippetPanel from ui.sidebar.variables_panel import VariablesPanel @@ -45,7 +46,11 @@ class _FlyoutPanel(QWidget): """Collapsible content panel placed as its own splitter child.""" - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, + request_history_panel: HistoryPanel | None = None, + parent: QWidget | None = None, + ) -> None: """Build title bar and all flyout content panels.""" super().__init__(parent) self.setObjectName("sidebarPanelArea") @@ -58,7 +63,15 @@ def __init__(self, parent: QWidget | None = None) -> None: layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - # Title bar + self.variables_panel = VariablesPanel() + self.snippet_panel = SnippetPanel() + self.saved_responses_panel = SavedResponsesPanel() + self.request_history_panel = request_history_panel or HistoryPanel() + self.snippet_panel.setSizePolicy( + QSizePolicy.Policy.Preferred, + QSizePolicy.Policy.Expanding, + ) + from PySide6.QtWidgets import QHBoxLayout title_bar = QHBoxLayout() @@ -70,6 +83,11 @@ def __init__(self, parent: QWidget | None = None) -> None: title_bar.addWidget(self.title_label) title_bar.addStretch() + self._history_refresh_btn = self.request_history_panel.refresh_button() + self._history_refresh_btn.setFixedSize(28, 28) + self._history_refresh_btn.hide() + title_bar.addWidget(self._history_refresh_btn) + self.close_btn = QPushButton() self.close_btn.setObjectName("iconButton") self.close_btn.setFixedSize(28, 28) @@ -79,21 +97,14 @@ def __init__(self, parent: QWidget | None = None) -> None: title_bar.addWidget(self.close_btn) layout.addLayout(title_bar) - - # 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) + layout.addWidget(self.request_history_panel, 1) self.variables_panel.hide() self.snippet_panel.hide() self.saved_responses_panel.hide() + self.request_history_panel.hide() def minimumSizeHint(self) -> QSize: """Enforce a readable minimum width for the flyout.""" @@ -110,7 +121,12 @@ class RightSidebar(QWidget): both the flyout and the rail into the parent splitter. """ - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, + parent: QWidget | None = None, + *, + request_history_panel: HistoryPanel | None = None, + ) -> None: """Initialise the icon rail and create the flyout panel.""" super().__init__(parent) self.setObjectName("sidebarRail") @@ -125,12 +141,13 @@ def __init__(self, parent: QWidget | None = None) -> None: self.setFixedWidth(self._rail_width) # --- Flyout (separate widget, placed in splitter later) ------- - self._flyout = _FlyoutPanel() + self._flyout = _FlyoutPanel(request_history_panel) self._close_btn = self._flyout.close_btn 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._request_history_panel = self._flyout.request_history_panel self._close_btn.clicked.connect(self._close_panel) # --- Rail layout ---------------------------------------------- @@ -144,11 +161,14 @@ def __init__(self, parent: QWidget | None = None) -> None: ) self._snippet_btn = self._make_rail_button("code", "Code snippet") self._saved_btn = self._make_rail_button("floppy-disk-back", "Saved responses") + self._history_btn = self._make_rail_button("clock-counter-clockwise", "History") self._snippet_btn.hide() self._saved_btn.hide() + self._history_btn.hide() rail_layout.addWidget(self._var_btn) rail_layout.addWidget(self._snippet_btn) rail_layout.addWidget(self._saved_btn) + rail_layout.addWidget(self._history_btn) rail_layout.addStretch() # State @@ -167,6 +187,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self._saved_btn.clicked.connect( lambda: self._toggle_panel("saved_responses"), ) + self._history_btn.clicked.connect( + lambda: self._toggle_panel("request_history"), + ) # Keep a reference for the ``_rail`` attribute used by tests. @property @@ -230,6 +253,11 @@ def saved_responses_panel(self) -> SavedResponsesPanel: """Return the saved responses panel widget.""" return self._saved_responses_panel + @property + def request_history_panel(self) -> HistoryPanel: + """Return the per-request send history panel widget.""" + return self._request_history_panel + @property def active_panel(self) -> str | None: """Return the key of the currently open panel, or *None*.""" @@ -260,13 +288,15 @@ def show_request_panels( auth: dict | None = None, ) -> None: """Configure the sidebar for a request tab.""" - self._available_panels = {"variables", "snippet", "saved_responses"} + self._available_panels = {"variables", "snippet", "saved_responses", "request_history"} 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._history_btn.show() + self._history_btn.setEnabled(True) self._variables_panel.load_variables( variables, @@ -295,13 +325,14 @@ def show_folder_panels( self._var_btn.setEnabled(True) self._snippet_btn.hide() self._saved_btn.hide() + self._history_btn.hide() self._variables_panel.load_variables( variables, has_environment=has_environment, ) - if self._active_panel in {"snippet", "saved_responses"}: + if self._active_panel in {"snippet", "saved_responses", "request_history"}: self._close_panel() def set_saved_response_context( @@ -327,16 +358,42 @@ def set_saved_response_context( return self._saved_responses_panel.set_saved_responses(items) + def set_request_history_context( + self, + *, + request_id: int | None, + request_name: str | None, + is_persisted_request: bool, + ) -> None: + """Populate the send-history panel for the active request context.""" + self._history_btn.setVisible(True) + self._history_btn.setEnabled(is_persisted_request) + self._request_history_panel.set_request_context( + request_id, + request_name, + is_persisted_request=is_persisted_request, + ) + if not is_persisted_request: + if self._active_panel == "request_history": + self._close_panel() + self._request_history_panel.show_request_required_state( + "Save the request first to browse history for this request." + ) + return + self._request_history_panel.refresh() + 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._history_btn.hide() self._close_panel() self._variables_panel.clear() self._snippet_panel.clear() self._saved_responses_panel.clear() + self._request_history_panel.clear() def open_panel(self, panel: str) -> None: """Programmatically open a specific panel by key.""" @@ -389,7 +446,7 @@ def _apply_rail_icon(self, btn: QToolButton, icon_name: str) -> None: def refresh_theme(self) -> None: """Re-render rail-button icons against the current palette.""" - for btn in (self._var_btn, self._snippet_btn, self._saved_btn): + for btn in (self._var_btn, self._snippet_btn, self._saved_btn, self._history_btn): name = btn.property("rail_icon_name") if isinstance(name, str) and name: self._apply_rail_icon(btn, name) @@ -408,15 +465,19 @@ def _show_panel(self, panel: str) -> None: self._variables_panel.setVisible(panel == "variables") self._snippet_panel.setVisible(panel == "snippet") self._saved_responses_panel.setVisible(panel == "saved_responses") + self._request_history_panel.setVisible(panel == "request_history") self._var_btn.setChecked(panel == "variables") self._snippet_btn.setChecked(panel == "snippet") self._saved_btn.setChecked(panel == "saved_responses") + self._history_btn.setChecked(panel == "request_history") titles = { "variables": "Variables", "snippet": "Code snippet", "saved_responses": "Saved Responses", + "request_history": "History", } self._title_label.setText(titles.get(panel, panel)) + self._flyout._history_refresh_btn.setVisible(panel == "request_history") self._flyout.show() self._expand_flyout() @@ -426,9 +487,12 @@ def _close_panel(self) -> None: self._variables_panel.hide() self._snippet_panel.hide() self._saved_responses_panel.hide() + self._request_history_panel.hide() self._var_btn.setChecked(False) self._snippet_btn.setChecked(False) self._saved_btn.setChecked(False) + self._history_btn.setChecked(False) + self._flyout._history_refresh_btn.hide() self._collapse_flyout() def _expand_flyout(self, target_width: int | None = None) -> None: diff --git a/src/ui/styling/global_qss.py b/src/ui/styling/global_qss.py index 7c0c883..9f0535c 100644 --- a/src/ui/styling/global_qss.py +++ b/src/ui/styling/global_qss.py @@ -269,6 +269,35 @@ def build_global_qss(p: ThemePalette) -> str: background: {"rgba(79, 193, 255, 0.40)" if p is DARK_PALETTE else "rgba(52, 152, 219, 0.40)"}; color: {p["solid_button_fg"]}; }} + QPushButton[objectName="requestHistoryReplayButton"] {{ + background: {p["accent"]}; + border: none; + border-radius: 4px; + }} + QPushButton[objectName="requestHistoryReplayButton"]:hover {{ + background: {p["accent_hover"]}; + }} + QPushButton[objectName="requestHistoryReplayButton"]:disabled {{ + background: {"rgba(79, 193, 255, 0.40)" if p is DARK_PALETTE else "rgba(52, 152, 219, 0.40)"}; + }} + QFrame[objectName="responseReplayIndicator"] {{ + background: {p["bg_alt"]}; + border: 1px solid {p["border"]}; + border-radius: 6px; + }} + QLabel[objectName="responseReplayPrefix"] {{ + color: {p["text_muted"]}; + font-size: 12px; + }} + QLabel[objectName="responseReplayLink"] {{ + color: {p["accent"]}; + font-size: 12px; + font-weight: 500; + text-decoration: underline; + }} + QLabel[objectName="responseReplayLink"]:hover {{ + color: {p["accent_hover"]}; + }} QPushButton[objectName="outlineButton"] {{ border: 1px solid {p["border"]}; padding: 4px 12px; @@ -1352,6 +1381,32 @@ def build_global_qss(p: ThemePalette) -> str: color: {p["text"]}; }} + QStackedWidget[objectName="requestHistoryList"] {{ + border: 1px solid {p["border"]}; + background: {p["input_bg"]}; + border-radius: 4px; + }} + QFrame[objectName="requestHistoryListEmpty"] {{ + background: transparent; + border: none; + }} + QTreeWidget[objectName="requestHistoryTree"] {{ + border: none; + background: transparent; + outline: none; + }} + QTreeWidget[objectName="requestHistoryTree"]::item {{ + padding: 6px 8px; + border: none; + }} + QTreeWidget[objectName="requestHistoryTree"]::item:hover {{ + background: {p["hover_tree_bg"]}; + }} + QTreeWidget[objectName="requestHistoryTree"]::item:selected {{ + background: {p["selected_bg"]}; + color: {p["text"]}; + }} + /* ---- Right sidebar ------------------------------------------ */ QWidget[objectName="sidebarPanelArea"] {{ background: {p["bg"]}; diff --git a/src/ui/styling/history_settings_manager.py b/src/ui/styling/history_settings_manager.py new file mode 100644 index 0000000..23bb3a9 --- /dev/null +++ b/src/ui/styling/history_settings_manager.py @@ -0,0 +1,164 @@ +"""History settings manager — reads/writes QSettings for send-history retention.""" + +from __future__ import annotations + +import logging + +from PySide6.QtCore import QObject, Signal + +from ui.styling.tab_settings_manager import _as_bool +from ui.styling.theme_manager import _APP, _ORG + +logger = logging.getLogger(__name__) + +_KEY_RETENTION_DAYS = "history/retention_days" +_KEY_MAX_ITEMS_PER_DAY = "history/max_items_per_day" +_KEY_UNLIMITED_PER_DAY = "history/unlimited_per_day" +_KEY_SAVE_RESPONSES = "history/save_responses" +_KEY_MAX_RESPONSE_BYTES = "history/max_response_bytes" + +DEFAULT_RETENTION_DAYS = 30 +MIN_RETENTION_DAYS = 1 +MAX_RETENTION_DAYS = 365 + +DEFAULT_MAX_ITEMS_PER_DAY = 100 +MIN_MAX_ITEMS_PER_DAY = 1 +MAX_MAX_ITEMS_PER_DAY = 10_000 + +DEFAULT_MAX_RESPONSE_BYTES = 1_048_576 +MAX_MAX_RESPONSE_BYTES = 10_485_760 +MIN_MAX_RESPONSE_BYTES = DEFAULT_MAX_RESPONSE_BYTES + + +def _clamp_int(value: object, default: int, low: int, high: int) -> int: + """Parse and clamp an integer QSettings value.""" + try: + parsed = int(str(value)) + except (TypeError, ValueError): + parsed = default + return max(low, min(high, parsed)) + + +class HistorySettingsManager(QObject): + """Persisted preferences for HTTP send history retention and body storage.""" + + settings_changed = Signal() + + def __init__(self, parent: QObject | None = None) -> None: + """Load history settings from QSettings.""" + super().__init__(parent) + from PySide6.QtCore import QSettings + + self._settings = QSettings(_ORG, _APP) + self._retention_days = _clamp_int( + self._settings.value(_KEY_RETENTION_DAYS, DEFAULT_RETENTION_DAYS), + DEFAULT_RETENTION_DAYS, + MIN_RETENTION_DAYS, + MAX_RETENTION_DAYS, + ) + self._max_items_per_day = _clamp_int( + self._settings.value(_KEY_MAX_ITEMS_PER_DAY, DEFAULT_MAX_ITEMS_PER_DAY), + DEFAULT_MAX_ITEMS_PER_DAY, + MIN_MAX_ITEMS_PER_DAY, + MAX_MAX_ITEMS_PER_DAY, + ) + self._unlimited_per_day = _as_bool( + self._settings.value(_KEY_UNLIMITED_PER_DAY, False), + False, + ) + self._save_responses = _as_bool( + self._settings.value(_KEY_SAVE_RESPONSES, True), + True, + ) + self._max_response_bytes = _clamp_int( + self._settings.value(_KEY_MAX_RESPONSE_BYTES, DEFAULT_MAX_RESPONSE_BYTES), + DEFAULT_MAX_RESPONSE_BYTES, + MIN_MAX_RESPONSE_BYTES, + MAX_MAX_RESPONSE_BYTES, + ) + + @property + def retention_days(self) -> int: + """Number of calendar days to retain history entries.""" + return self._retention_days + + @retention_days.setter + def retention_days(self, value: int) -> None: + clamped = _clamp_int(value, DEFAULT_RETENTION_DAYS, MIN_RETENTION_DAYS, MAX_RETENTION_DAYS) + if self._retention_days == clamped: + return + self._retention_days = clamped + self._settings.setValue(_KEY_RETENTION_DAYS, clamped) + self.settings_changed.emit() + + @property + def max_items_per_day(self) -> int: + """Maximum history entries kept per local calendar day.""" + return self._max_items_per_day + + @max_items_per_day.setter + def max_items_per_day(self, value: int) -> None: + clamped = _clamp_int( + value, + DEFAULT_MAX_ITEMS_PER_DAY, + MIN_MAX_ITEMS_PER_DAY, + MAX_MAX_ITEMS_PER_DAY, + ) + if self._max_items_per_day == clamped: + return + self._max_items_per_day = clamped + self._settings.setValue(_KEY_MAX_ITEMS_PER_DAY, clamped) + self.settings_changed.emit() + + @property + def unlimited_per_day(self) -> bool: + """When true, do not cap entries per calendar day.""" + return self._unlimited_per_day + + @unlimited_per_day.setter + def unlimited_per_day(self, value: bool) -> None: + parsed = bool(value) + if self._unlimited_per_day == parsed: + return + self._unlimited_per_day = parsed + self._settings.setValue(_KEY_UNLIMITED_PER_DAY, parsed) + self.settings_changed.emit() + + @property + def save_responses(self) -> bool: + """When false, skip writing response body files (snapshots still saved).""" + return self._save_responses + + @save_responses.setter + def save_responses(self, value: bool) -> None: + parsed = bool(value) + if self._save_responses == parsed: + return + self._save_responses = parsed + self._settings.setValue(_KEY_SAVE_RESPONSES, parsed) + self.settings_changed.emit() + + @property + def max_response_bytes(self) -> int: + """Maximum bytes stored per response body file.""" + return self._max_response_bytes + + @max_response_bytes.setter + def max_response_bytes(self, value: int) -> None: + clamped = _clamp_int( + value, + DEFAULT_MAX_RESPONSE_BYTES, + MIN_MAX_RESPONSE_BYTES, + MAX_MAX_RESPONSE_BYTES, + ) + if self._max_response_bytes == clamped: + return + self._max_response_bytes = clamped + self._settings.setValue(_KEY_MAX_RESPONSE_BYTES, clamped) + self.settings_changed.emit() + + def max_response_bytes_for_storage(self) -> int: + """Return the byte cap for body files, or ``0`` when bodies are not saved.""" + if not self._save_responses: + return 0 + return self._max_response_bytes diff --git a/tests/AGENTS.md b/tests/AGENTS.md index c7a7eb4..33cffe7 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -18,8 +18,8 @@ group and the ``ui/kv_col_widths`` key (key-value table column widths) so preview/tab-limit settings and persisted table columns never leak between cases. -4b. **Settings → Scripting tests** — `test_settings_dialog` uses an - autouse fixture that removes `scripting` (as well as `theme` and `tabs`) +4b. **Settings dialog tests** — `test_settings_dialog` uses an autouse fixture + that removes `scripting` and `history` (as well as `theme` and `tabs`) **before and after** each test so a fake persisted `scripting/deno_path` from the Apply test cannot affect later test modules in the same session (which would break Deno-based script tests). Mypy: that module has a @@ -37,6 +37,15 @@ 9. **Do not test the session or engine directly** — test through the repository or service layer. +## Isolated user-data and send-history directories (autouse fixtures) + +`conftest.py` provides `_isolated_postmark_user_data`, which monkeypatches +`postmark_user_data_dir()` to a per-test folder under `tmp_path`. + +`_isolated_request_history` monkeypatches `user_history_root()` to a temp folder. +**Required** so `init_db()` → `reconcile_orphans()` on an empty per-test DB does +not delete the developer's real `~/.local/share/postmark/history/` payloads. + ## Fresh database per test (autouse fixture) `conftest.py` provides a `_fresh_db` fixture that resets the module-level @@ -127,7 +136,7 @@ test file still exceeds 600 lines, split by test class into separate files. ``` tests/ ├── conftest.py # Root: configure_before_qapplication + _fresh_db + _reset_tab_settings + _disable_script_lsp_in_tests + _shutdown_lsp_clients + _reset_code_editor_popups_after_test (autouse) + qapp -├── qt_popup_cleanup.py # reset_code_editor_popups + flush_deferred_widget_deletes (shared by root + ui conftest) +├── qt_popup_cleanup.py # reset_code_editor_popups + dismiss_all_top_level_test_widgets ├── esprima_test_util.py # deno_and_esprima_available() for JS parse-dependent tests ├── unit/ # Pure logic — no Qt widgets │ ├── database/ # Repository layer tests @@ -142,7 +151,10 @@ tests/ │ │ ├── test_request_assertion_repository.py │ │ ├── test_script_version_local_script.py │ │ ├── test_environment_repository.py -│ │ └── test_run_history_repository.py +│ │ ├── test_run_history_repository.py +│ │ ├── test_data_paths.py +│ │ ├── test_request_history_body_store.py +│ │ └── test_request_history_repository.py │ ├── local_scripts/ # Script filename display helpers │ │ └── test_script_filename.py │ └── services/ # Service layer tests @@ -178,6 +190,9 @@ tests/ │ ├── test_python_format.py │ ├── test_script_error_format.py │ ├── test_runtime_settings.py +│ ├── test_request_history_service.py +│ ├── test_request_history_replay.py +│ ├── test_request_history_snapshot_headers.py │ ├── test_secret_store.py # SecretStore backends: keyring / encrypted-file / noop; default-store self-test fallback │ ├── test_deno_runtime_registries.py # _build_npmrc_text + deno_ipc_argv_and_env private-registry plumbing │ ├── test_cjs_deno_interop.py # Gate 0 Deno ``import *`` from ``.cjs`` @@ -217,6 +232,7 @@ tests/ ├── test_main_window_draft.py # Draft tab open/save lifecycle tests ├── test_main_window_session.py # Tab session persistence (save/restore) tests ├── local_scripts/ + │ ├── conftest.py # local_script_editor fixture (WA_DontShowOnScreen) + autouse teardown │ └── test_local_script_editor_widget.py # LocalScriptEditorWidget auto-save + async LSP prep defer tests ├── styling/ # Theme and icon tests │ ├── test_theme_manager.py @@ -260,7 +276,9 @@ tests/ │ ├── test_debug_variables_watches.py │ ├── test_debug_metadata_persist.py │ ├── test_snippets_sidebar_panel.py - │ └── test_saved_responses_panel.py + │ ├── test_saved_responses_panel.py + │ ├── test_request_history_panel.py + │ └── test_right_sidebar_request_history.py ├── collections/ # Collection sidebar tests │ ├── test_collection_header.py │ ├── test_collection_tree.py @@ -297,6 +315,7 @@ tests/ ├── test_request_editor_search.py ├── test_assertions_tab.py ├── test_response_viewer.py + ├── test_response_replay_indicator.py ├── test_response_viewer_search.py ├── test_response_viewer_tests.py ├── test_version_history.py @@ -349,14 +368,18 @@ When testing PySide6 widgets: 7. For signal-to-service integration, emit signals directly on the tree widget and verify the DB changed via `CollectionService`. -Shared helpers (`make_collection_dict`, `top_level_items`) live in -`tests/ui/conftest.py` and can be imported via relative import from any -subfolder: +Shared helpers (`make_collection_dict`, `top_level_items`, +`finish_main_window_startup`) live in `tests/ui/conftest.py` and can be +imported via relative import from any subfolder: ```python -from ..conftest import make_collection_dict, top_level_items +from ..conftest import finish_main_window_startup, make_collection_dict, top_level_items ``` +Call ``finish_main_window_startup(window)`` after ``MainWindow`` construction +when a test depends on session tab restore (replaces ``load_finished.emit()`` +alone). + ## Assertions and error testing - Use plain `assert` — pytest rewrites them for rich diffs. diff --git a/tests/conftest.py b/tests/conftest.py index 64ea022..404e046 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,7 +30,7 @@ # QApplication (session-scoped, created once for all UI tests) # ------------------------------------------------------------------ @pytest.fixture(scope="session") -def qapp() -> QApplication: +def qapp() -> Generator[QApplication, None, None]: """Return the single QApplication instance shared across all tests. If an instance already exists (e.g. from pytest-qt) it is reused; @@ -39,14 +39,36 @@ def qapp() -> QApplication: app = QApplication.instance() if not isinstance(app, QApplication): app = QApplication([]) - return app + app.setApplicationName("PostmarkTests") + app.setApplicationDisplayName("Postmark Tests") + yield app + from tests.qt_popup_cleanup import dismiss_all_top_level_test_widgets + + dismiss_all_top_level_test_widgets(app) # ------------------------------------------------------------------ # Fresh database (autouse, per-test) # ------------------------------------------------------------------ @pytest.fixture(autouse=True) -def _fresh_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): +def _isolated_request_history(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep send-history payloads out of the developer's real user-data history tree. + + Without this, ``init_db()`` → ``reconcile_orphans()`` on an empty per-test + SQLite DB would delete bodies in the real storage directory while metadata + in ``data/database/main.db`` remained — the “erased after restart” symptom. + """ + history = tmp_path / "history" + + def _history() -> Path: + history.mkdir(parents=True, exist_ok=True) + return history + + monkeypatch.setattr("database.data_paths.user_history_root", _history) + + +@pytest.fixture(autouse=True) +def _fresh_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, _isolated_request_history: None): """Provide every test with a fresh, isolated SQLite database. The database is created in a temporary directory and torn down @@ -68,6 +90,18 @@ def _fresh_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): db_mod._SessionLocal = None +@pytest.fixture(autouse=True) +def _isolated_postmark_user_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Redirect ``postmark_user_data_dir()`` to a temp folder for every test.""" + root = tmp_path / "postmark" + + def _root() -> Path: + root.mkdir(parents=True, exist_ok=True) + return root + + monkeypatch.setattr("database.data_paths.postmark_user_data_dir", _root) + + @pytest.fixture(autouse=True) def _isolated_lsp_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Give every test its own Deno LSP workspace directory. @@ -127,11 +161,13 @@ def _reset_code_editor_popups_after_test(qapp: QApplication) -> Generator[None, """Dismiss app-wide completion/hint popups so non-``tests/ui`` Qt tests cannot leave windows up.""" yield from tests.qt_popup_cleanup import ( + dismiss_all_top_level_test_widgets, flush_deferred_widget_deletes, reset_code_editor_popups, ) reset_code_editor_popups() + dismiss_all_top_level_test_widgets(qapp) flush_deferred_widget_deletes(qapp) diff --git a/tests/qt_popup_cleanup.py b/tests/qt_popup_cleanup.py index ac58d0b..ebb9e0a 100644 --- a/tests/qt_popup_cleanup.py +++ b/tests/qt_popup_cleanup.py @@ -2,10 +2,23 @@ from __future__ import annotations -from PySide6.QtCore import QEvent -from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QEvent, Qt +from PySide6.QtWidgets import QApplication, QWidget from shiboken6 import Shiboken +# Top-level widgets created by tests that call ``show()`` without a real parent. +_TEST_TOP_LEVEL_TYPES = frozenset( + { + "LocalScriptEditorWidget", + "MainWindow", + "HistoryPanel", + "RequestEditorWidget", + "FolderEditorWidget", + "CollectionWidget", + "SavedResponsesPanel", + } +) + def reset_code_editor_popups() -> None: """Hide and disconnect shared code-editor popups so they do not leak onto the desktop.""" @@ -31,3 +44,35 @@ def reset_code_editor_popups() -> None: def flush_deferred_widget_deletes(qapp: QApplication) -> None: """Process queued ``DeferredDelete`` events after popup/editor teardown.""" qapp.sendPostedEvents(None, int(QEvent.Type.DeferredDelete)) + + +def _is_test_leak_top_level(widget: QWidget, qapp: QApplication) -> bool: + """Return True if *widget* looks like a pytest-created freestanding window.""" + if widget is qapp or not Shiboken.isValid(widget): + return False + if widget.parent() is not None: + return False + if type(widget).__name__ in _TEST_TOP_LEVEL_TYPES: + return True + if hasattr(widget, "_pane") and hasattr(getattr(widget, "_pane", None), "output_panel"): + return True + title = widget.windowTitle() + return title.endswith(".py") or title == "conftest.py" + + +def dismiss_all_top_level_test_widgets(qapp: QApplication) -> None: + """Close every orphan top-level widget (visible or hidden) except ``qapp`` itself.""" + for widget in list(QApplication.topLevelWidgets()): + if not _is_test_leak_top_level(widget, qapp): + continue + widget.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True) + widget.hide() + widget.close() + widget.deleteLater() + flush_deferred_widget_deletes(qapp) + qapp.processEvents() + + +def dismiss_orphan_editor_windows(qapp: QApplication) -> None: + """Backward-compatible alias for autouse teardown hooks.""" + dismiss_all_top_level_test_widgets(qapp) diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index 8d1d9d0..5249905 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -75,17 +75,36 @@ def _reset_popup_and_flush_widgets(qapp: QApplication) -> Iterator[None]: # 2. Reset code-editor popup singletons (see ``tests/qt_popup_cleanup.py``). from tests.qt_popup_cleanup import ( + dismiss_all_top_level_test_widgets, flush_deferred_widget_deletes, reset_code_editor_popups, ) reset_code_editor_popups() + dismiss_all_top_level_test_widgets(qapp) flush_deferred_widget_deletes(qapp) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ +def finish_main_window_startup(window: object) -> None: + """Emit ``load_finished`` when needed and run session restore to completion.""" + from ui.main_window.session_restore import flush_session_restore + + if window._main_stack.currentIndex() == 0: # type: ignore[attr-defined] + window.collection_widget.load_finished.emit() # type: ignore[attr-defined] + flush_session_restore(window) # type: ignore[arg-type] + + +@pytest.fixture(autouse=True) +def _inline_local_project_config_sync(monkeypatch: pytest.MonkeyPatch) -> None: + """Avoid background mirror sync threads in UI tests (sync on demand elsewhere).""" + from ui.main_window import MainWindow + + monkeypatch.setattr(MainWindow, "_start_local_project_config_sync", lambda self: None) + + def make_collection_dict( collections: list[dict[str, Any]], ) -> dict[str, Any]: diff --git a/tests/ui/dialogs/test_settings_dialog.py b/tests/ui/dialogs/test_settings_dialog.py index 58d83b5..7ab7d50 100644 --- a/tests/ui/dialogs/test_settings_dialog.py +++ b/tests/ui/dialogs/test_settings_dialog.py @@ -10,6 +10,7 @@ from PySide6.QtWidgets import QApplication from ui.dialogs.settings_dialog import SettingsDialog +from ui.styling.history_settings_manager import HistorySettingsManager from ui.styling.tab_settings_manager import ( ACTIVATE_MRU, LIMIT_CLOSE_UNUSED, @@ -41,12 +42,14 @@ def _clear_theme_settings() -> Generator[None, None, None]: settings.remove("theme") settings.remove("tabs") settings.remove("scripting") + settings.remove("history") settings.sync() yield settings = QSettings(_ORG, _APP) settings.remove("theme") settings.remove("tabs") settings.remove("scripting") + settings.remove("history") settings.sync() @@ -96,7 +99,7 @@ def test_top_level_categories_present(self, qapp: QApplication, qtbot) -> None: dialog._cat_tree.topLevelItem(i).text(0) for i in range(dialog._cat_tree.topLevelItemCount()) ] - assert labels == ["Appearance", "Tabs", "Scripting", "Private packages"] + assert labels == ["Appearance", "Tabs", "Scripting", "History", "Private packages"] def test_private_packages_has_provider_children(self, qapp: QApplication, qtbot) -> None: """Private packages has npm / JSR / PyPI children.""" @@ -374,6 +377,62 @@ def test_apply_persists_lsp_enabled(self, qapp: QApplication, qtbot) -> None: assert RuntimeSettings.lsp_enabled() is True +class TestSettingsDialogHistory: + """Request history retention settings.""" + + def test_history_page_widgets_exist(self, qapp: QApplication, qtbot) -> None: + """History page exposes retention controls.""" + tm = ThemeManager(qapp) + hm = HistorySettingsManager() + dialog = SettingsDialog(tm, history_settings_manager=hm) + qtbot.addWidget(dialog) + w = dialog._history_widgets + assert w is not None + assert w.retention_days_spin is not None + assert w.unlimited_check is not None + assert w.max_mib_spin is not None + + def test_apply_persists_history_settings(self, qapp: QApplication, qtbot) -> None: + """Apply writes history/* QSettings keys.""" + tm = ThemeManager(qapp) + hm = HistorySettingsManager() + dialog = SettingsDialog(tm, history_settings_manager=hm) + qtbot.addWidget(dialog) + w = dialog._history_widgets + assert w is not None + w.retention_days_spin.setValue(14) + w.unlimited_check.setChecked(True) + w.max_mib_spin.setValue(2) + dialog._on_apply() + assert hm.retention_days == 14 + assert hm.unlimited_per_day is True + assert hm.max_response_bytes == 2 * 1024 * 1024 + + def test_unlimited_toggle_disables_max_items_spin(self, qapp: QApplication, qtbot) -> None: + """Unlimited per day disables the per-day cap spin.""" + tm = ThemeManager(qapp) + hm = HistorySettingsManager() + dialog = SettingsDialog(tm, history_settings_manager=hm) + qtbot.addWidget(dialog) + w = dialog._history_widgets + assert w is not None + w.unlimited_check.setChecked(True) + w.unlimited_check.toggled.emit(True) + assert w.max_items_spin.isEnabled() is False + + def test_max_mib_minimum_on_apply(self, qapp: QApplication, qtbot) -> None: + """Apply stores at least 1 MiB for max response body size.""" + tm = ThemeManager(qapp) + hm = HistorySettingsManager() + dialog = SettingsDialog(tm, history_settings_manager=hm) + qtbot.addWidget(dialog) + w = dialog._history_widgets + assert w is not None + w.max_mib_spin.setValue(1) + dialog._on_apply() + assert hm.max_response_bytes >= 1024 * 1024 + + class TestSettingsDialogPrivatePackages: """Private package registries: table, default-npm, PyPI.""" diff --git a/tests/ui/local_scripts/conftest.py b/tests/ui/local_scripts/conftest.py new file mode 100644 index 0000000..503ac59 --- /dev/null +++ b/tests/ui/local_scripts/conftest.py @@ -0,0 +1,40 @@ +"""Fixtures for local-script UI tests.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from ui.local_scripts.local_script_editor_widget import LocalScriptEditorWidget + + +@pytest.fixture +def local_script_editor(qtbot) -> Iterator[LocalScriptEditorWidget]: + """``LocalScriptEditorWidget`` that never becomes a desktop window during tests.""" + editor = LocalScriptEditorWidget() + editor.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True) + qtbot.addWidget(editor) + yield editor + + +@pytest.fixture(autouse=True) +def _teardown_local_script_editors() -> Iterator[None]: + """Cancel async LSP prep and tear down any stray script-editor top-level windows.""" + yield + from shiboken6 import Shiboken + + from tests.qt_popup_cleanup import dismiss_all_top_level_test_widgets + + app = QApplication.instance() + if not isinstance(app, QApplication): + return + for widget in app.allWidgets(): + if not Shiboken.isValid(widget): + continue + pane = getattr(widget, "_pane", None) + if pane is not None and hasattr(pane, "cancel_async_lsp_prep"): + pane.cancel_async_lsp_prep() + dismiss_all_top_level_test_widgets(app) diff --git a/tests/ui/local_scripts/test_local_script_editor_widget.py b/tests/ui/local_scripts/test_local_script_editor_widget.py index 0457349..a24d489 100644 --- a/tests/ui/local_scripts/test_local_script_editor_widget.py +++ b/tests/ui/local_scripts/test_local_script_editor_widget.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Iterator from typing import Any from unittest.mock import MagicMock @@ -19,49 +18,42 @@ from ui.local_scripts.local_script_editor_widget import LocalScriptEditorWidget -@pytest.fixture -def _cancel_local_script_lsp_prep_after_test() -> Iterator[None]: - """Stop any in-flight LSP prep worker started during a test.""" - yield - app = QApplication.instance() - if isinstance(app, QApplication): - app.processEvents() - - -def test_autosave_persist_writes_script_content(qapp: QApplication) -> None: +def test_autosave_persist_writes_script_content( + local_script_editor: LocalScriptEditorWidget, +) -> None: """Auto-save callback persists buffer text to the database.""" folder = create_folder("Scripts") script = create_script(folder.id, "Helper", language="javascript", content="original") - editor = LocalScriptEditorWidget() - editor.load_script(LocalScriptService.get_script_load_dict(script.id) or {}) - editor._pane.editor.setPlainText("updated body") - editor._persist_for_autosave() + local_script_editor.load_script(LocalScriptService.get_script_load_dict(script.id) or {}) + local_script_editor._pane.editor.setPlainText("updated body") + local_script_editor._persist_for_autosave() loaded = LocalScriptService.get_script_load_dict(script.id) assert loaded is not None assert loaded["content"] == "updated body" - assert not editor.is_dirty() + assert not local_script_editor.is_dirty() -def test_js_local_script_defers_lsp_attach_until_loaded(qapp: QApplication) -> None: +def test_js_local_script_defers_lsp_attach_until_loaded( + local_script_editor: LocalScriptEditorWidget, +) -> None: """LSP attach stays deferred at init; with LSP disabled in tests, sync attach after load.""" folder = create_folder("Mods") script = create_script(folder.id, "Entry", language="javascript", content="export const x = 1;") - editor = LocalScriptEditorWidget() - assert editor._pane.editor._lsp_attach_deferred is True + assert local_script_editor._pane.editor._lsp_attach_deferred is True - editor.load_script(LocalScriptService.get_script_load_dict(script.id) or {}) + local_script_editor.load_script(LocalScriptService.get_script_load_dict(script.id) or {}) # Tests autouse-disable LSP, so async prep falls back to immediate attach. - assert editor._pane.editor._lsp_attach_deferred is False - assert editor._pane._local_script_id == script.id + assert local_script_editor._pane.editor._lsp_attach_deferred is False + assert local_script_editor._pane._local_script_id == script.id def test_js_local_script_async_prep_keeps_deferred_until_finalize( + local_script_editor: LocalScriptEditorWidget, qapp: QApplication, monkeypatch: pytest.MonkeyPatch, - _cancel_local_script_lsp_prep_after_test: None, ) -> None: """When async prep is enabled, load returns before LSP attach finalizes.""" folder = create_folder("AsyncMods") @@ -85,26 +77,23 @@ def start(self) -> None: _SlowWorker, ) - editor = LocalScriptEditorWidget() - editor.show() - editor.load_script(LocalScriptService.get_script_load_dict(script.id) or {}) + local_script_editor.load_script(LocalScriptService.get_script_load_dict(script.id) or {}) qapp.processEvents() assert started, "expected async prep worker to start" - assert editor.isVisible() - assert "export const y" in editor._pane.editor.toPlainText() - assert editor._pane.editor._lsp_attach_deferred is True - assert editor._pane._local_script_id == script.id + assert "export const y" in local_script_editor._pane.editor.toPlainText() + assert local_script_editor._pane.editor._lsp_attach_deferred is True + assert local_script_editor._pane._local_script_id == script.id - editor._pane.cancel_async_lsp_prep() + local_script_editor._pane.cancel_async_lsp_prep() settings.setValue("scripting/lsp_enabled", False) qapp.processEvents() def test_load_script_does_not_build_module_index_on_gui_thread( + local_script_editor: LocalScriptEditorWidget, qapp: QApplication, monkeypatch: pytest.MonkeyPatch, - _cancel_local_script_lsp_prep_after_test: None, ) -> None: """``load_script`` does not scan the module index on the GUI thread; prep does later.""" folder = create_folder("GuiIndex") @@ -149,13 +138,11 @@ def start(self) -> None: _SlowWorker, ) - editor = LocalScriptEditorWidget() - editor.show() - editor.load_script(data) + local_script_editor.load_script(data) qapp.processEvents() assert index_calls == 0 - assert editor._pane.editor._lsp_attach_deferred is True + assert local_script_editor._pane.editor._lsp_attach_deferred is True result = prepare_local_script_lsp_attach( script_id=script.id, @@ -166,6 +153,6 @@ def start(self) -> None: assert result.ok is True assert index_calls >= 1 - editor._pane.cancel_async_lsp_prep() + local_script_editor._pane.cancel_async_lsp_prep() settings.setValue("scripting/lsp_enabled", False) qapp.processEvents() diff --git a/tests/ui/request/test_response_replay_indicator.py b/tests/ui/request/test_response_replay_indicator.py new file mode 100644 index 0000000..709d03e --- /dev/null +++ b/tests/ui/request/test_response_replay_indicator.py @@ -0,0 +1,78 @@ +"""Tests for the response viewer replayed-send indicator.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from ui.request.response_viewer import ResponseViewerWidget + + +class TestResponseReplayIndicator: + """Replay banner visibility and link signal.""" + + def test_set_and_clear_replay_source(self, qapp: QApplication, qtbot) -> None: + """Banner shows when a replay source is set and hides when cleared.""" + viewer = ResponseViewerWidget() + qtbot.addWidget(viewer) + assert viewer._replay_indicator.isHidden() + + viewer.set_replay_history_source(42, "View GET 400 (2024-06-01)") + assert not viewer._replay_indicator.isHidden() + assert viewer._replay_indicator._link.text() == "View GET 400 (2024-06-01)" + + viewer.clear_replay_history_source() + assert viewer._replay_indicator.isHidden() + + def test_link_emits_entry_id(self, qapp: QApplication, qtbot) -> None: + """Clicking the link emits replay_history_link_clicked with the entry id.""" + viewer = ResponseViewerWidget() + qtbot.addWidget(viewer) + viewer.set_replay_history_source(7, "View send") + + with qtbot.waitSignal(viewer.replay_history_link_clicked, timeout=2000) as blocker: + qtbot.mouseClick(viewer._replay_indicator._link, Qt.MouseButton.LeftButton) + + assert blocker.args == [7] + + +class TestHistoryPanelFocusEntry: + """History panel focus_entry selects a row.""" + + def test_focus_entry_selects_tree_row( + self, qapp: QApplication, qtbot, tmp_path, monkeypatch + ) -> None: + """focus_entry expands the date group and selects the target row.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from services.collection_service import CollectionService + from services.request_history_service import RequestHistoryService + from ui.sidebar.history.panel import HistoryPanel + from ui.styling.history_settings_manager import HistorySettingsManager + + svc = CollectionService() + coll = svc.create_collection("C") + req = svc.create_request(coll.id, "GET", "http://example.com", "R") + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "R", + "method": "GET", + "url": "http://example.com", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "headers": [], "body": "ok"}, + original_request={"method": "GET"}, + settings=settings, + ) + assert entry_id is not None + + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_request_context(req.id, "R", is_persisted_request=True) + panel.refresh() + + assert panel.focus_entry(entry_id) + assert panel._current_entry_id == entry_id diff --git a/tests/ui/sidebar/test_request_history_panel.py b/tests/ui/sidebar/test_request_history_panel.py new file mode 100644 index 0000000..d62e30c --- /dev/null +++ b/tests/ui/sidebar/test_request_history_panel.py @@ -0,0 +1,385 @@ +"""Tests for the per-request send-history sidebar panel.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication, QTreeWidget + +from services.request_history_service import RequestHistoryService +from ui.sidebar.history.delegate import ROLE_HISTORY_IS_DATE_GROUP +from ui.sidebar.history.helpers import ( + build_history_row_meta, + first_history_entry_id, + group_entries_by_local_date, + populate_history_tree_widget, +) +from ui.sidebar.history.panel import HistoryPanel + + +def _entry_count(tree: QTreeWidget) -> int: + """Count send rows (exclude date group parents).""" + total = 0 + for index in range(tree.topLevelItemCount()): + group = tree.topLevelItem(index) + if group is not None: + total += group.childCount() + return total + + +class TestRequestHistoryPanel: + """Tests for HistoryPanel in request-scoped mode.""" + + def test_construction_starts_without_request(self, qapp: QApplication, qtbot) -> None: + """Panel starts in a request-required empty state.""" + panel = HistoryPanel() + qtbot.addWidget(panel) + assert panel.objectName() == "requestHistoryPanel" + assert panel._tree_widget.objectName() == "requestHistoryTree" + assert "Open a saved request" in panel._state_label.text() + + def test_draft_shows_save_first_message(self, qapp: QApplication, qtbot) -> None: + """Unsaved request tabs show the save-first empty state.""" + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Draft", is_persisted_request=False) + panel.show_request_required_state( + "Save the request first to browse history for this request." + ) + assert "Save the request first" in panel._state_label.text() + assert panel._content_splitter.isHidden() + + def test_refresh_populates_tree_and_detail( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Persisted request refresh loads tree rows and detail body text.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://example.com", "Example") + settings = HistorySettingsManager() + RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Example", + "method": "GET", + "url": "http://example.com", + }, + response={ + "status_code": 200, + "elapsed_ms": 5.0, + "headers": [{"key": "Content-Type", "value": "text/plain"}], + "body": "hello", + }, + original_request={"method": "GET", "url": "http://example.com", "body": "x"}, + settings=settings, + ) + + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_request_context(req.id, "Example", is_persisted_request=True) + panel.refresh() + + assert panel._history_search_input.objectName() == "requestHistorySearch" + assert panel._tree_widget.topLevelItemCount() == 1 + group = panel._tree_widget.topLevelItem(0) + assert group is not None + assert group.data(0, ROLE_HISTORY_IS_DATE_GROUP) + assert group.text(0) == "Today" + assert group.childCount() == 1 + assert _entry_count(panel._tree_widget) == 1 + assert first_history_entry_id(panel._tree_widget) is not None + assert "Example" in panel._detail_name.text() + assert "hello" in panel._body_edit.toPlainText() + + def test_search_filters_by_status( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Search box filters rows by HTTP status code.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://example.com", "Example") + settings = HistorySettingsManager() + RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Ok", + "method": "GET", + "url": "http://example.com/ok", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "headers": [], "body": "a"}, + original_request={"method": "GET"}, + settings=settings, + ) + RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Bad", + "method": "GET", + "url": "http://example.com/bad", + }, + response={"status_code": 400, "elapsed_ms": 1.0, "headers": [], "body": "b"}, + original_request={"method": "GET"}, + settings=settings, + ) + + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_request_context(req.id, "Example", is_persisted_request=True) + panel.refresh() + assert _entry_count(panel._tree_widget) == 2 + + panel._history_search_input.setText("400") + qtbot.wait(50) + assert panel._tree_widget.topLevelItemCount() == 1 + assert _entry_count(panel._tree_widget) == 1 + assert "Bad" in panel._detail_name.text() + + def test_search_no_match_keeps_search_visible( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Empty search results keep the search field and browse layout visible.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://example.com", "Example") + settings = HistorySettingsManager() + RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Example", + "method": "GET", + "url": "http://example.com", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "headers": [], "body": "ok"}, + original_request={"method": "GET"}, + settings=settings, + ) + + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.show() + qtbot.waitExposed(panel) + panel.set_request_context(req.id, "Example", is_persisted_request=True) + panel.refresh() + + panel._history_search_input.setText("999") + qtbot.wait(50) + + assert not panel._history_search_input.isHidden() + assert not panel._content_splitter.isHidden() + assert panel._state_label.isHidden() + assert panel._list_stack.currentIndex() == 0 + assert 'No history matches "999"' in panel._list_empty_label.text() + + def test_collapse_date_group_keeps_replay_enabled( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Collapsing a date group must not clear the active send or disable replay.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://example.com", "Example") + settings = HistorySettingsManager() + RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Example", + "method": "GET", + "url": "http://example.com", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "headers": [], "body": "ok"}, + original_request={"method": "GET", "url": "http://example.com"}, + settings=settings, + ) + + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.show() + qtbot.waitExposed(panel) + panel.set_request_context(req.id, "Example", is_persisted_request=True) + panel.refresh() + + entry_id = first_history_entry_id(panel._tree_widget) + assert entry_id is not None + assert panel._replay_btn.isEnabled() + + group = panel._tree_widget.topLevelItem(0) + assert group is not None + panel._tree_widget.setCurrentItem(group) + + assert panel._current_entry_id == entry_id + assert panel._replay_btn.isEnabled() + + def test_group_entries_by_local_date(self) -> None: + """Rows group under Today when executed_at is now.""" + from datetime import UTC, datetime + + now = datetime.now(UTC).isoformat() + groups = group_entries_by_local_date( + [ + {"id": 1, "executed_at": now}, + {"id": 2, "executed_at": now}, + ] + ) + assert len(groups) == 1 + assert groups[0][0] == "Today" + assert len(groups[0][1]) == 2 + + def test_populate_history_tree_widget_builds_date_groups( + self, qapp: QApplication, qtbot + ) -> None: + """Tree population creates expandable date parents with child sends.""" + from datetime import UTC, datetime + + tree = QTreeWidget() + qtbot.addWidget(tree) + now = datetime.now(UTC).isoformat() + populate_history_tree_widget( + tree, + [ + {"id": 10, "executed_at": now, "request_name": "A", "method": "GET"}, + {"id": 11, "executed_at": now, "request_name": "B", "method": "POST"}, + ], + ) + assert tree.topLevelItemCount() == 1 + group = tree.topLevelItem(0) + assert group is not None + assert group.data(0, ROLE_HISTORY_IS_DATE_GROUP) + assert group.isExpanded() + assert group.childCount() == 2 + + def test_deleted_label_in_row_meta( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Orphaned rows expose (deleted) via source_label in list metadata.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + delete_request, + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://x", "Gone") + settings = HistorySettingsManager() + RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Gone", + "method": "GET", + "url": "http://x", + }, + response={"status_code": 204, "elapsed_ms": 1.0, "headers": [], "body": ""}, + original_request={"method": "GET"}, + settings=settings, + ) + delete_request(req.id) + + items = RequestHistoryService.list_for_request(99999) + assert items == [] + + rows = RequestHistoryService.list_for_sidebar() + assert any(r.get("request_id") is None for r in rows) + orphaned = [r for r in rows if r.get("source_label") == "(deleted)"] + assert orphaned + assert "(deleted)" in build_history_row_meta(orphaned[0]) + + def test_replay_button_emits_signal( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Replay button emits replay_requested with the selected entry id.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://example.com", "Example") + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Example", + "method": "GET", + "url": "http://example.com", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "headers": [], "body": "ok"}, + original_request={"method": "GET", "url": "http://example.com"}, + settings=settings, + ) + assert entry_id is not None + + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_request_context(req.id, "Example", is_persisted_request=True) + panel.refresh() + + with qtbot.waitSignal(panel.replay_requested, timeout=2000) as blocker: + qtbot.mouseClick(panel._replay_btn, Qt.MouseButton.LeftButton) + assert blocker.args == [entry_id] diff --git a/tests/ui/sidebar/test_right_sidebar_request_history.py b/tests/ui/sidebar/test_right_sidebar_request_history.py new file mode 100644 index 0000000..7eaf5b8 --- /dev/null +++ b/tests/ui/sidebar/test_right_sidebar_request_history.py @@ -0,0 +1,61 @@ +"""Tests for send-history integration on the right sidebar rail.""" + +from __future__ import annotations + +from PySide6.QtWidgets import QApplication + +from ui.sidebar.history.panel import HistoryPanel +from ui.sidebar.sidebar_widget import RightSidebar + + +class TestRightSidebarRequestHistory: + """Send history as the fourth right-rail flyout button.""" + + def test_history_panel_on_sidebar(self, qapp: QApplication, qtbot) -> None: + """RightSidebar exposes the request history panel.""" + panel = HistoryPanel() + sidebar = RightSidebar(request_history_panel=panel) + qtbot.addWidget(sidebar) + assert sidebar.request_history_panel is panel + + def test_history_button_hidden_until_request_tab(self, qapp: QApplication, qtbot) -> None: + """History rail button is hidden until show_request_panels.""" + sidebar = RightSidebar() + qtbot.addWidget(sidebar) + assert sidebar._history_btn.isHidden() + + sidebar.show_request_panels({}, method="GET", url="http://x") + assert not sidebar._history_btn.isHidden() + + sidebar.show_folder_panels({}) + assert sidebar._history_btn.isHidden() + + def test_set_request_history_context_draft_disables_button( + self, qapp: QApplication, qtbot + ) -> None: + """Draft context disables the history button and shows save-first state.""" + sidebar = RightSidebar() + qtbot.addWidget(sidebar) + sidebar.show_request_panels({}, method="GET", url="http://x") + sidebar.set_request_history_context( + request_id=None, + request_name="Draft", + is_persisted_request=False, + ) + assert not sidebar._history_btn.isEnabled() + assert "Save the request first" in sidebar.request_history_panel._state_label.text() + + def test_open_request_history_panel(self, qapp: QApplication, qtbot) -> None: + """open_panel('request_history') selects the send-history flyout.""" + sidebar = RightSidebar() + qtbot.addWidget(sidebar) + sidebar.show_request_panels({}, method="GET", url="http://x") + sidebar.set_request_history_context( + request_id=1, + request_name="Req", + is_persisted_request=True, + ) + sidebar.open_panel("request_history") + assert sidebar.active_panel == "request_history" + assert sidebar._history_btn.isChecked() + assert sidebar.request_history_panel.isVisible() diff --git a/tests/ui/test_main_window_session.py b/tests/ui/test_main_window_session.py index 208dcc6..c698e3b 100644 --- a/tests/ui/test_main_window_session.py +++ b/tests/ui/test_main_window_session.py @@ -8,6 +8,7 @@ from PySide6.QtWidgets import QApplication from services.collection_service import CollectionService +from tests.ui.conftest import finish_main_window_startup from ui.main_window import MainWindow from ui.styling.tab_settings_manager import TabSettingsManager @@ -227,7 +228,7 @@ def test_restore_opens_saved_request_tabs(self, qapp: QApplication, qtbot) -> No qtbot.addWidget(window) # Simulate load_finished which triggers _restore_tabs - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 2 assert window._tab_bar.currentIndex() == 1 @@ -250,7 +251,7 @@ def test_restore_opens_folder_tabs(self, qapp: QApplication, qtbot) -> None: window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 1 ctx = window._tabs[0] @@ -271,7 +272,7 @@ def test_restore_opens_left_sidebar_panel(self, qapp: QApplication, qtbot) -> No window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._left_sidebar.active_panel == "local_scripts" assert window._left_sidebar.is_open @@ -303,7 +304,7 @@ def test_restore_opens_local_script_tabs(self, qapp: QApplication, qtbot) -> Non window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 1 ctx = window._tabs[0] @@ -336,7 +337,7 @@ def test_restore_opens_environments_tab_between_requests( window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 3 env_ctx = window._tabs.get(1) @@ -359,7 +360,7 @@ def test_restore_skips_deleted_request(self, qapp: QApplication, qtbot) -> None: window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 0 @@ -380,7 +381,7 @@ def test_restore_skips_deleted_collection(self, qapp: QApplication, qtbot) -> No qtbot.addWidget(window) warning.assert_not_called() - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 0 @@ -389,7 +390,7 @@ def test_restore_does_nothing_when_no_session(self, qapp: QApplication, qtbot) - window = MainWindow() qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 0 @@ -413,7 +414,7 @@ def test_restore_handles_mixed_valid_and_deleted(self, qapp: QApplication, qtbot window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 1 assert window.request_widget._url_input.text() == "http://alive.com" @@ -435,7 +436,7 @@ def test_restore_clamps_active_index(self, qapp: QApplication, qtbot) -> None: window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) # Should not crash; tab 0 is the only option assert window._tab_bar.count() == 1 @@ -460,7 +461,7 @@ def test_restore_ignores_unknown_tab_type(self, qapp: QApplication, qtbot) -> No window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 1 @@ -523,7 +524,7 @@ def test_restore_reopens_draft_tab(self, qapp: QApplication, qtbot) -> None: window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 1 assert window.request_widget._url_input.text() == "http://draft.test" @@ -550,7 +551,7 @@ def test_restore_draft_with_custom_name(self, qapp: QApplication, qtbot) -> None window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 1 ctx = window._tabs[0] @@ -569,7 +570,7 @@ def test_restore_draft_skips_missing_data(self, qapp: QApplication, qtbot) -> No window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 0 @@ -596,7 +597,7 @@ def test_persist_mixed_request_and_draft(self, qapp: QApplication, qtbot) -> Non window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 2 # Tab 0: persisted request @@ -632,7 +633,7 @@ def test_deferred_tabs_create_chips_without_editor(self, qapp: QApplication, qtb window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 2 # Active tab (0) is materialised @@ -663,7 +664,7 @@ def test_selecting_deferred_tab_materialises_it(self, qapp: QApplication, qtbot) window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) # Switch to the deferred tab window._tab_bar.setCurrentIndex(1) @@ -695,7 +696,7 @@ def test_deferred_deleted_request_removed_on_materialise( window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) assert window._tab_bar.count() == 2 # Select the deferred tab pointing to a deleted request @@ -720,7 +721,7 @@ def test_old_format_falls_back_to_eager(self, qapp: QApplication, qtbot) -> None window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) # Eagerly materialised — no deferred entry assert 0 in window._tabs @@ -747,7 +748,7 @@ def test_close_deferred_tab(self, qapp: QApplication, qtbot) -> None: window = MainWindow(tab_settings_manager=tab_settings) qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + finish_main_window_startup(window) # Close the deferred tab (index 1) window._on_tab_close(1) diff --git a/tests/ui/test_main_window_tab_nav_history.py b/tests/ui/test_main_window_tab_nav_history.py index 23af4da..8c23427 100644 --- a/tests/ui/test_main_window_tab_nav_history.py +++ b/tests/ui/test_main_window_tab_nav_history.py @@ -193,7 +193,9 @@ def test_go_menu_contains_tab_nav_actions(self, qapp: QApplication, qtbot) -> No """Go menu lists tab Back and Forward actions.""" window = MainWindow() qtbot.addWidget(window) - window.collection_widget.load_finished.emit() + from tests.ui.conftest import finish_main_window_startup + + finish_main_window_startup(window) menubar = window.menuBar() go_top = next( (a for a in menubar.actions() if a.text().replace("&", "") == "Go"), diff --git a/tests/unit/database/test_data_paths.py b/tests/unit/database/test_data_paths.py new file mode 100644 index 0000000..1f1d176 --- /dev/null +++ b/tests/unit/database/test_data_paths.py @@ -0,0 +1,26 @@ +"""Tests for database.data_paths helpers.""" + +from __future__ import annotations + +import database.data_paths as data_paths +from database.data_paths import project_root, user_history_root + + +def test_project_root_is_repo_not_src() -> None: + """SQLite and project ``data/`` live at the repo root, not under ``src/``.""" + root = project_root() + assert root.name != "src" + assert (root / "src" / "main.py").is_file() + assert (root / "data" / "database").is_dir() + + +def test_user_history_under_postmark_user_data_dir(tmp_path, monkeypatch) -> None: + """History bodies/snapshots use the OS user-data dir, not the project tree.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + history = user_history_root() + assert history == tmp_path / "postmark" / "history" + assert data_paths.postmark_user_data_dir() == tmp_path / "postmark" + assert project_root() / "data" / "database" != history diff --git a/tests/unit/database/test_request_history_body_store.py b/tests/unit/database/test_request_history_body_store.py new file mode 100644 index 0000000..811e350 --- /dev/null +++ b/tests/unit/database/test_request_history_body_store.py @@ -0,0 +1,165 @@ +"""Tests for request-history file storage.""" + +from __future__ import annotations + +import database.data_paths as data_paths +from database.models.request_history import body_store + + +def test_write_and_read_body() -> None: + """Body files are written under bodies/ and read back.""" + rel = body_store.write_body(7, b"hello") + assert rel == "bodies/7.bin" + assert body_store.read_body(rel) == b"hello" + assert (data_paths.user_history_root() / "bodies" / "7.bin").is_file() + + +def test_write_and_read_snapshot() -> None: + """Request snapshots round-trip as JSON.""" + snap = {"method": "GET", "url": "http://example.com"} + rel = body_store.write_request_snapshot(3, snap) + assert rel == "requests/3.json" + loaded = body_store.read_request_snapshot(rel) + assert loaded == snap + + +def test_read_missing_returns_none() -> None: + """Missing files return None without raising.""" + assert body_store.read_body("bodies/missing.bin") is None + assert body_store.read_request_snapshot("requests/missing.json") is None + + +def test_read_legacy_history_prefix_and_project_data_dir(tmp_path, monkeypatch) -> None: + """Rows stored as ``history/bodies/…`` load from project ``data/history``.""" + monkeypatch.setattr( + "database.models.request_history.body_store.data_paths.project_root", + lambda: tmp_path / "repo", + ) + legacy = tmp_path / "repo" / "data" / "history" / "bodies" + legacy.mkdir(parents=True) + (legacy / "1.bin").write_bytes(b'{"error":true}') + + loaded = body_store.read_body("history/bodies/1.bin") + assert loaded == b'{"error":true}' + + +def test_migrate_legacy_paths_normalizes_db_and_copies_files(tmp_path, monkeypatch) -> None: + """Startup migration fixes path strings and copies bodies into user-data.""" + monkeypatch.setattr( + "database.models.request_history.body_store.data_paths.project_root", + lambda: tmp_path / "repo", + ) + from database.database import get_session + from database.models.request_history import request_history_repository + from database.models.request_history.model.request_history_entry_model import ( + RequestHistoryEntryModel, + ) + + row = request_history_repository.insert_entry( + request_id=None, + request_name="R", + method="GET", + url="http://example.com", + status_code=200, + elapsed_ms=1.0, + error=None, + response_headers=[], + response_body=b"migrated", + original_request=None, + save_responses=True, + max_response_bytes=1024, + retention_days=30, + max_items_per_day=100, + unlimited_per_day=True, + ) + entry_id = int(row["id"]) + primary = data_paths.user_history_root() / "bodies" / f"{entry_id}.bin" + legacy_dir = tmp_path / "repo" / "data" / "history" / "bodies" + legacy_dir.mkdir(parents=True, exist_ok=True) + legacy_file = legacy_dir / f"{entry_id}.bin" + legacy_file.write_bytes(primary.read_bytes()) + primary.unlink() + + with get_session() as session: + loaded = session.get(RequestHistoryEntryModel, entry_id) + assert loaded is not None + loaded.response_body_path = f"history/bodies/{entry_id}.bin" + session.flush() + + body_store.migrate_legacy_paths_and_files() + with get_session() as session: + loaded = session.get(RequestHistoryEntryModel, entry_id) + assert loaded is not None + assert loaded.response_body_path == f"bodies/{entry_id}.bin" + assert body_store.read_body(f"bodies/{entry_id}.bin") == b"migrated" + assert primary.is_file() + + +def test_reconcile_orphans_keeps_files_for_known_entry_ids() -> None: + """Orphan sweep keeps ``bodies/{entry_id}.bin`` when the row id exists.""" + from database.models.request_history import request_history_repository + + row = request_history_repository.insert_entry( + request_id=None, + request_name="R", + method="GET", + url="http://example.com", + status_code=200, + elapsed_ms=1.0, + error=None, + response_headers=[], + response_body=b"keep", + original_request=None, + save_responses=True, + max_response_bytes=1024, + retention_days=30, + max_items_per_day=100, + unlimited_per_day=True, + ) + entry_id = int(row["id"]) + path = data_paths.user_history_root() / "bodies" / f"{entry_id}.bin" + assert path.is_file() + body_store.reconcile_orphans() + assert path.is_file() + assert path.read_bytes() == b"keep" + + +def test_reconcile_orphans_removes_stray_file() -> None: + """Orphan files under bodies/ are removed when not referenced in the DB.""" + from database.models.request_history import request_history_repository + + row = request_history_repository.insert_entry( + request_id=None, + request_name="R", + method="GET", + url="http://example.com", + status_code=200, + elapsed_ms=1.0, + error=None, + response_headers=[], + response_body=b"keep", + original_request=None, + save_responses=True, + max_response_bytes=1024, + retention_days=30, + max_items_per_day=100, + unlimited_per_day=True, + ) + entry_id = int(row["id"]) + bodies = data_paths.user_history_root() / "bodies" + orphan = bodies / "99.bin" + orphan.write_bytes(b"orphan") + body_store.reconcile_orphans() + assert not orphan.exists() + assert (bodies / f"{entry_id}.bin").is_file() + + +def test_reconcile_skips_when_db_empty_but_body_files_exist() -> None: + """Do not delete real payloads when the DB has no rows (e.g. isolated test DB).""" + bodies = data_paths.user_history_root() / "bodies" + bodies.mkdir(parents=True, exist_ok=True) + survivor = bodies / "42.bin" + survivor.write_bytes(b"payload") + body_store.reconcile_orphans() + assert survivor.is_file() + assert survivor.read_bytes() == b"payload" diff --git a/tests/unit/database/test_request_history_repository.py b/tests/unit/database/test_request_history_repository.py new file mode 100644 index 0000000..924526a --- /dev/null +++ b/tests/unit/database/test_request_history_repository.py @@ -0,0 +1,176 @@ +"""Tests for request_history_repository.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + delete_request, +) +from database.models.request_history import request_history_repository + + +def _insert(**kwargs: Any) -> dict[str, Any]: + defaults: dict[str, Any] = { + "request_id": None, + "request_name": "Req", + "method": "GET", + "url": "http://example.com", + "status_code": 200, + "elapsed_ms": 1.0, + "error": None, + "response_headers": [], + "response_body": b"ok", + "original_request": {"method": "GET", "url": "http://example.com"}, + "save_responses": True, + "max_response_bytes": 1024, + "retention_days": 30, + "max_items_per_day": 100, + "unlimited_per_day": True, + } + defaults.update(kwargs) + return request_history_repository.insert_entry(**defaults) + + +def test_insert_writes_files_and_paths(tmp_path, monkeypatch) -> None: + """Insert stores relative paths and creates body + snapshot files.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + row = _insert() + assert row["was_persisted_request"] is False + assert row["response_body_path"] == f"bodies/{row['id']}.bin" + assert row["request_snapshot_path"] == f"requests/{row['id']}.json" + full = request_history_repository.get_entry(int(row["id"])) + assert full is not None + assert full["body"] == b"ok" + assert full["original_request"]["url"] == "http://example.com" + + +def test_save_responses_false_skips_body_file(tmp_path, monkeypatch) -> None: + """When save_responses is false, snapshot is still written.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + row = _insert(save_responses=False, response_body=b"skip") + assert row["response_body_path"] is None + assert row["request_snapshot_path"] is not None + + +def test_nullify_request_id_on_delete(tmp_path, monkeypatch) -> None: + """Deleting a request nullifies history request_id.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://x", "R") + row = _insert(request_id=req.id, request_name="R") + delete_request(req.id) + loaded = request_history_repository.get_entry(int(row["id"])) + assert loaded is not None + assert loaded["request_id"] is None + + +def test_prune_drops_old_rows(tmp_path, monkeypatch) -> None: + """Rows older than retention_days are removed.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + row = _insert(retention_days=1, unlimited_per_day=True) + entry_id = int(row["id"]) + from database.database import get_session + from database.models.request_history.model.request_history_entry_model import ( + RequestHistoryEntryModel, + ) + + with get_session() as session: + model = session.get(RequestHistoryEntryModel, entry_id) + assert model is not None + model.executed_at = datetime.now(tz=UTC) - timedelta(days=5) + request_history_repository.prune_old_entries( + retention_days=1, + max_items_per_day=100, + unlimited_per_day=True, + ) + assert request_history_repository.get_entry(entry_id) is None + + +def test_body_truncated_when_over_max_bytes(tmp_path, monkeypatch) -> None: + """Response bodies longer than max_response_bytes are truncated on insert.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + row = _insert(response_body=b"abcdef", max_response_bytes=4) + loaded = request_history_repository.get_entry(int(row["id"])) + assert loaded is not None + assert loaded["body_truncated"] is True + assert loaded["body"] == b"abcd" + + +def test_was_persisted_request_set_for_saved_request(tmp_path, monkeypatch) -> None: + """Persisted sends set was_persisted_request so legacy NOT NULL columns are satisfied.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://x", "R") + row = _insert(request_id=req.id, request_name="R") + assert row["was_persisted_request"] is True + + +def test_list_for_request_filters_by_request_id(tmp_path, monkeypatch) -> None: + """list_for_request returns only rows for the given request_id.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + coll = create_new_collection("C") + req_a = create_new_request(coll.id, "GET", "http://a", "A") + req_b = create_new_request(coll.id, "GET", "http://b", "B") + row_a = _insert(request_id=req_a.id, request_name="A", url="http://a") + _insert(request_id=req_b.id, request_name="B", url="http://b") + _insert(request_id=None, request_name="Draft", url="http://draft") + + listed = request_history_repository.list_for_request(req_a.id) + assert len(listed) == 1 + assert listed[0]["id"] == row_a["id"] + assert listed[0]["request_id"] == req_a.id + + +def test_list_for_request_search_by_url_and_status(tmp_path, monkeypatch) -> None: + """Search filters by URL substring and exact status code.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://host", "R") + _insert( + request_id=req.id, + request_name="Ok", + url="http://host/ok", + status_code=200, + ) + bad = _insert( + request_id=req.id, + request_name="Bad", + url="http://host/error", + status_code=400, + ) + + by_url = request_history_repository.list_for_request(req.id, search="error") + assert len(by_url) == 1 + assert by_url[0]["id"] == bad["id"] + + by_status = request_history_repository.list_for_request(req.id, search="400") + assert len(by_status) == 1 + assert by_status[0]["status_code"] == 400 diff --git a/tests/unit/services/test_request_history_replay.py b/tests/unit/services/test_request_history_replay.py new file mode 100644 index 0000000..1476b9b --- /dev/null +++ b/tests/unit/services/test_request_history_replay.py @@ -0,0 +1,92 @@ +"""Tests for send-history replay helpers.""" + +from __future__ import annotations + +from typing import cast + +from services.request_history_service import RequestHistoryEntryDict, RequestHistoryService + + +def test_build_replay_request_dict_uses_snapshot_then_metadata() -> None: + """Replay data prefers the stored snapshot and falls back to row fields.""" + entry = cast( + RequestHistoryEntryDict, + { + "method": "POST", + "url": "http://fallback", + "request_name": "Name", + "original_request": {"method": "GET", "url": "http://snap", "body": "{}"}, + }, + ) + data = RequestHistoryService.build_replay_request_dict(entry) + assert data["method"] == "GET" + assert data["url"] == "http://snap" + assert data["body"] == "{}" + + +def test_can_replay_entry_requires_url() -> None: + """Replay is disabled when no URL is available.""" + assert RequestHistoryService.can_replay_entry({"method": "GET", "url": ""}) is False + assert RequestHistoryService.can_replay_entry({"method": "GET", "url": "http://x"}) is True + + +def test_build_send_payload_uses_sent_headers() -> None: + """Replay send uses sent_headers from the snapshot, not editor auth reinjection.""" + entry = cast( + RequestHistoryEntryDict, + { + "method": "GET", + "url": "http://example.com", + "original_request": { + "method": "GET", + "url": "http://example.com", + "sent_headers": [{"key": "Authorization", "value": "Bearer tok"}], + }, + }, + ) + payload = RequestHistoryService.build_send_payload_from_entry(entry) + assert payload is not None + assert "Authorization: Bearer tok" in (payload["headers"] or "") + assert payload["url"] == "http://example.com" + + +def test_replay_source_link_text_includes_method_and_time() -> None: + """Replay banner link text references method, status, and timestamp.""" + entry = cast( + RequestHistoryEntryDict, + { + "method": "GET", + "status_code": 400, + "executed_at": "2024-06-01T13:34:42+00:00", + }, + ) + text = RequestHistoryService.replay_source_link_text(entry) + assert "GET" in text + assert "400" in text + assert "View" in text + + +def test_delete_entry_removes_row_and_files(tmp_path, monkeypatch) -> None: + """delete_entry removes the DB row and payload files.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "R", + "method": "GET", + "url": "http://example.com", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "headers": [], "body": "ok"}, + original_request={"method": "GET"}, + settings=settings, + ) + assert entry_id is not None + assert RequestHistoryService.get_entry(entry_id) is not None + assert RequestHistoryService.delete_entry(entry_id) is True + assert RequestHistoryService.get_entry(entry_id) is None diff --git a/tests/unit/services/test_request_history_service.py b/tests/unit/services/test_request_history_service.py new file mode 100644 index 0000000..c726331 --- /dev/null +++ b/tests/unit/services/test_request_history_service.py @@ -0,0 +1,41 @@ +"""Tests for RequestHistoryService.""" + +from __future__ import annotations + +from services.request_history_service import RequestHistoryService, SendIdentityDict +from ui.styling.history_settings_manager import HistorySettingsManager + + +def test_record_send_and_detail_snapshot(tmp_path, monkeypatch, qapp) -> None: + """record_send persists rows readable via get_entry and detail snapshot.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + settings = HistorySettingsManager() + identity: SendIdentityDict = { + "request_id": None, + "request_name": "", + "method": "POST", + "url": "http://example.com", + } + response = { + "status_code": 201, + "elapsed_ms": 12.5, + "headers": [{"key": "Content-Type", "value": "text/plain"}], + "body": "created", + } + snap = {"method": "POST", "url": "http://example.com", "body": "x"} + entry_id = RequestHistoryService.record_send( + identity=identity, + response=response, + original_request=snap, + settings=settings, + ) + assert entry_id is not None + entry = RequestHistoryService.get_entry(entry_id) + assert entry is not None + assert entry["source_label"] == "(draft)" # unsaved tab: no stored request name + detail = RequestHistoryService.entry_to_detail_snapshot(entry) + assert detail["body"] == "created" + assert detail["original_request"]["url"] == "http://example.com" diff --git a/tests/unit/services/test_request_history_snapshot_headers.py b/tests/unit/services/test_request_history_snapshot_headers.py new file mode 100644 index 0000000..85e60e4 --- /dev/null +++ b/tests/unit/services/test_request_history_snapshot_headers.py @@ -0,0 +1,34 @@ +"""Tests for send-history snapshot header capture.""" + +from __future__ import annotations + +from services.request_history_service import RequestHistoryService +from ui.sidebar.history.helpers import extract_history_request_headers + + +def test_enrich_snapshot_stores_sent_headers() -> None: + """Auth-injected headers from the worker are stored on the snapshot.""" + snap = {"method": "GET", "url": "http://example.com", "headers": []} + response = { + "request_method": "GET", + "request_url": "http://example.com/ok", + "request_headers": [ + {"key": "Authorization", "value": "Bearer secret"}, + {"key": "X-Custom", "value": "1"}, + ], + } + merged = RequestHistoryService.enrich_snapshot_for_history(snap, response) + assert merged["url"] == "http://example.com/ok" + assert merged["sent_headers"] == response["request_headers"] + text = extract_history_request_headers(merged) + assert "Authorization: Bearer secret" in text + assert "X-Custom: 1" in text + + +def test_extract_history_request_headers_falls_back_to_editor_headers() -> None: + """Older rows without sent_headers still show editor header rows.""" + snap = { + "headers": [{"key": "Accept", "value": "application/json"}], + } + text = extract_history_request_headers(snap) + assert "Accept: application/json" in text From 8957419c445932a244c03a70247688bdca17c031 Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Tue, 2 Jun 2026 15:22:53 +0300 Subject: [PATCH 3/7] feat: Add request duplication and enhance request history features - Introduced `duplicate_request` method to clone requests along with their saved responses and assertions, ensuring unique naming for duplicates. - Updated `RequestHistoryService` to include new methods for managing request history, including enhanced metadata handling and date filtering. - Enhanced UI components to support the new duplication feature and improved history navigation, including global and per-request history panels. - Updated documentation to reflect changes in request management and history features, ensuring clarity on new functionalities. - Refactored related services and models to accommodate the new request duplication logic and maintain consistency across the application. --- .../service-repository-reference/SKILL.md | 18 +- .agents/skills/signal-flow/SKILL.md | 10 +- AGENTS.md | 10 +- docs/README.md | 1 + .../services/collection-service.md | 11 + .../services/request-history-service.md | 93 +++++ docs/api-reference/signals.md | 1 + docs/api-reference/typedicts.md | 10 + docs/ui-reference/collections.md | 2 +- docs/ui-reference/main-window.md | 2 +- docs/ui-reference/response-viewer.md | 4 +- docs/ui-reference/sidebar.md | 125 ++++-- src/AGENTS.md | 28 +- .../collection_query_repository.py | 6 + .../collections/collection_repository.py | 88 +++- .../request_history_repository.py | 77 +++- src/services/collection_service.py | 13 + src/services/environment_service.py | 31 +- src/services/request_history_service.py | 166 +++++++- src/ui/AGENTS.md | 3 + src/ui/collections/collection_widget.py | 27 ++ src/ui/collections/tree/collection_tree.py | 1 + src/ui/collections/tree/tree_actions.py | 4 + src/ui/main_window/draft_controller.py | 14 +- .../history_navigation/__init__.py | 5 + .../main_window/history_navigation/mixin.py | 231 +++++++++++ .../history_navigation/orphan_open.py | 67 ++++ src/ui/main_window/send_pipeline.py | 37 +- .../main_window/send_pipeline_postresponse.py | 3 + src/ui/main_window/tab_controller.py | 132 ++++-- src/ui/main_window/variable_controller.py | 127 +++++- src/ui/main_window/window.py | 9 + src/ui/request/http_worker.py | 4 + src/ui/request/navigation/tab_manager.py | 1 + .../request/response_viewer/viewer_widget.py | 110 ++++- .../sidebar/history/date_filter/__init__.py | 5 + src/ui/sidebar/history/date_filter/mixin.py | 131 ++++++ src/ui/sidebar/history/date_filter/popup.py | 146 +++++++ src/ui/sidebar/history/delegate.py | 36 +- src/ui/sidebar/history/detail/__init__.py | 5 + src/ui/sidebar/history/detail/loader.py | 67 ++++ .../sidebar/history/global_mode/__init__.py | 6 + src/ui/sidebar/history/global_mode/mixin.py | 120 ++++++ .../history/global_mode/open_filter.py | 41 ++ src/ui/sidebar/history/helpers.py | 19 +- src/ui/sidebar/history/panel.py | 344 +++++++++++++--- src/ui/sidebar/left_sidebar.py | 21 +- src/ui/sidebar/sidebar_widget.py | 68 +++- src/ui/styling/global_qss.py | 12 + src/ui/widgets/text_format_async/__init__.py | 21 + src/ui/widgets/text_format_async/helpers.py | 11 + src/ui/widgets/text_format_async/runner.py | 49 +++ src/ui/widgets/text_format_async/worker.py | 47 +++ tests/AGENTS.md | 3 + .../test_collection_tree_actions.py | 40 ++ tests/ui/request/test_response_viewer.py | 53 ++- .../test_global_history_open_navigation.py | 375 ++++++++++++++++++ tests/ui/sidebar/test_global_history_panel.py | 337 ++++++++++++++++ .../test_left_sidebar_global_history.py | 26 ++ .../ui/sidebar/test_request_history_panel.py | 2 +- tests/ui/sidebar/test_sidebar.py | 24 ++ tests/ui/test_main_window_session.py | 31 ++ tests/unit/database/test_repository.py | 73 ++++ .../test_request_history_repository.py | 73 ++++ .../unit/services/test_environment_service.py | 14 + .../services/test_request_history_service.py | 142 +++++++ tests/unit/services/test_service.py | 14 + .../unit/ui/widgets/test_text_format_async.py | 35 ++ .../ui/widgets/test_text_format_helpers.py | 18 + 69 files changed, 3629 insertions(+), 251 deletions(-) create mode 100644 docs/api-reference/services/request-history-service.md create mode 100644 src/ui/main_window/history_navigation/__init__.py create mode 100644 src/ui/main_window/history_navigation/mixin.py create mode 100644 src/ui/main_window/history_navigation/orphan_open.py create mode 100644 src/ui/sidebar/history/date_filter/__init__.py create mode 100644 src/ui/sidebar/history/date_filter/mixin.py create mode 100644 src/ui/sidebar/history/date_filter/popup.py create mode 100644 src/ui/sidebar/history/detail/__init__.py create mode 100644 src/ui/sidebar/history/detail/loader.py create mode 100644 src/ui/sidebar/history/global_mode/__init__.py create mode 100644 src/ui/sidebar/history/global_mode/mixin.py create mode 100644 src/ui/sidebar/history/global_mode/open_filter.py create mode 100644 src/ui/widgets/text_format_async/__init__.py create mode 100644 src/ui/widgets/text_format_async/helpers.py create mode 100644 src/ui/widgets/text_format_async/runner.py create mode 100644 src/ui/widgets/text_format_async/worker.py create mode 100644 tests/ui/sidebar/test_global_history_open_navigation.py create mode 100644 tests/ui/sidebar/test_global_history_panel.py create mode 100644 tests/ui/sidebar/test_left_sidebar_global_history.py create mode 100644 tests/unit/ui/widgets/test_text_format_async.py create mode 100644 tests/unit/ui/widgets/test_text_format_helpers.py diff --git a/.agents/skills/service-repository-reference/SKILL.md b/.agents/skills/service-repository-reference/SKILL.md index 4d643cc..686a18f 100644 --- a/.agents/skills/service-repository-reference/SKILL.md +++ b/.agents/skills/service-repository-reference/SKILL.md @@ -21,6 +21,8 @@ cross-layer data interchange. | `create_new_request(collection_id, method, url, name, ...)` | `RequestModel` | Create a request | | `rename_request(request_id, new_name)` | `None` | Update name | | `delete_request(request_id)` | `None` | Delete a single request | +| `duplicate_request(request_id)` | `RequestModel` | Clone request + saved responses + assertions; unique `` Copy`` name | +| `unique_duplicate_request_name(base_name, existing_names)` | `str` | ``{base} Copy`` or ``{base} Copy N`` | | `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 | @@ -88,8 +90,9 @@ Metadata in SQLite; bodies/snapshots via `body_store.py` under |----------|---------|---------| | `insert_entry(...)` | `dict[str, Any]` | Insert row + write body/snapshot files; truncate body to `max_response_bytes` | | `get_entry(entry_id)` | `dict \| None` | Row + loaded body/snapshot bytes | -| `list_entries_for_sidebar(search?, limit?)` | `list[dict]` | Newest-first global list (future left rail) | -| `list_for_request(request_id, search?, limit?)` | `list[dict]` | Newest-first rows for one persisted `request_id` | +| `list_entries_for_sidebar(search?, limit?)` | `list[dict]` | Newest-first global list (left rail); search always capped by `limit` | +| `list_for_request(request_id, search?, limit?)` | `list[dict]` | Newest-first rows for one persisted `request_id`; search capped | +| `delete_entry(entry_id)` | `bool` | Delete one row and on-disk payload files | | `prune_old_entries(retention_days, max_items_per_day, unlimited_per_day)` | `None` | Drop rows older than retention and over per-day cap | | `nullify_request_id(request_id)` | `None` | Set `request_id` NULL when collection request deleted | | `local_date(executed_at)` | `date` | Local calendar date for per-day caps | @@ -137,6 +140,7 @@ directly to the repository with no added logic. | `create_request(collection_id, method, url, name, ...)` | `name.strip()`, `method.upper()`, rejects empty | | `rename_request(id, new_name)` | `new_name.strip()`, rejects empty | | `delete_request(id)` | Logging only | +| `duplicate_request(id)` | Logging only; returns new `RequestModel` | | `move_request(id, new_collection_id)` | Passthrough | | `update_collection(id, **fields)` | Passthrough (generic field update) | | `update_request(id, **fields)` | Passthrough (generic field update) | @@ -238,12 +242,16 @@ Module-level functions; class re-exports them as `@staticmethod` aliases. |--------|---------| | `gather_send_identity(ctx, editor, data)` | Capture method/url/name at send start | | `record_send(identity, response, original_request, settings)` | Persist send; prune per settings; return entry id | -| `list_for_sidebar(search?)` | List all entries (global; future left rail) | +| `list_for_sidebar(search?)` | List all entries (left-rail global History) | +| `entry_to_http_response_dict(entry)` | Map stored entry → `ResponseViewer.load_stored_response` dict | | `list_for_request(request_id, search?)` | List sends for one saved request (right rail) | | `get_entry(entry_id)` | Full row with file payloads | -| `entry_to_detail_snapshot(entry)` | Shape for future response viewer replay | +| `entry_to_detail_snapshot(entry)` | Shape for HistoryPanel read-only detail tabs | +| `build_replay_request_dict(entry)` | Editor load dict from snapshot | +| `build_send_payload_from_entry(entry)` | HTTP replay worker payload | +| `delete_entry(entry_id)` | Remove row and payload files | -TypedDicts: `SendIdentityDict`, `RequestHistoryEntryDict`. +TypedDicts: `SendIdentityDict`, `RequestHistoryEntryDict` (includes `was_persisted_request` for `(deleted)` / `(draft)` labels). Settings: `HistorySettingsManager` (`history/retention_days`, `max_items_per_day`, `unlimited_per_day`, `save_responses`, `max_response_bytes`). diff --git a/.agents/skills/signal-flow/SKILL.md b/.agents/skills/signal-flow/SKILL.md index dd31afb..aa85107 100644 --- a/.agents/skills/signal-flow/SKILL.md +++ b/.agents/skills/signal-flow/SKILL.md @@ -274,7 +274,15 @@ MainWindow._refresh_sidebar (request tab) on_send_finished → _record_request_history → RequestHistoryService.record_send(...) - → HistoryPanel.refresh() when recorded request_id matches active tab + → _request_history_panel.refresh() when recorded request_id matches active tab + → _global_history_panel.refresh() always + +HistoryPanel.entry_open_requested(int entry_id) [global instance only] + → MainWindow._open_from_global_history + → existing request: _open_request + right History schedule_detail_load (async detail) + + RightSidebar.open_panel("request_history") + focus_entry (deferred) + → orphan/deleted: _open_draft_request + load_request(snapshot) + load_stored_response + (draft sidebar: History/Saved Responses disabled) HistoryPanel.replay_requested(int entry_id) → MainWindow._replay_request_history_entry diff --git a/AGENTS.md b/AGENTS.md index c56db25..d5d852f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -288,6 +288,7 @@ src/ ├── main_window/ # Top-level MainWindow sub-package │ ├── window.py # MainWindow widget + signal wiring │ ├── send_pipeline.py # _SendPipelineMixin — HTTP send (re-exports debug-hover helpers) + │ ├── history_navigation/ # _HistoryNavigationMixin — open global history into tabs + right History │ ├── send_pipeline_debug.py # _merge_debug_hover_values, _debug_hover_root_objects, … │ ├── send_pipeline_postresponse.py # on_send_finished, run_post_response_script_with_live_response │ ├── send_pipeline_debug_session.py # on_debug_paused/step/finished, end_debug_ui @@ -325,8 +326,9 @@ src/ │ │ ├── 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 - │ └── history/ # Per-request History flyout (right rail) + │ └── history/ # Send-history flyouts (right per-request + left global); date_filter/ popup + mixin │ ├── panel.py # HistoryPanel — list/detail + requestHistorySearch + │ ├── global_mode/ # Global mode mixin + Enter key filter (left rail) │ ├── panel_detail_tabs.py # Read-only Headers / Request Headers / Request Body tabs │ ├── delegate.py # HistoryEntryDelegate — status badge + date group headers │ ├── helpers.py # Date grouping, list populate, row meta, sent headers @@ -550,10 +552,14 @@ tests/ ├── sidebar/ # Sidebar widget tests │ ├── test_sidebar.py │ ├── test_left_sidebar.py + │ ├── test_left_sidebar_global_history.py │ ├── test_variables_panel.py │ ├── test_snippet_panel.py │ ├── test_debug_panel.py - │ └── test_saved_responses_panel.py + │ ├── test_saved_responses_panel.py + │ ├── test_request_history_panel.py + │ ├── test_global_history_panel.py + │ └── test_global_history_open_navigation.py ├── widgets/ # Shared component tests │ ├── test_code_editor.py │ ├── test_code_editor_folding.py diff --git a/docs/README.md b/docs/README.md index 7704b69..67c3a3e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,6 +56,7 @@ | [Import Parsers](api-reference/services/import-parsers.md) | Postman, cURL, and URL parser modules | | [ScriptEngine](api-reference/services/script-engine.md) | Script execution engine, runtimes, context builders | | [ScriptService](api-reference/services/script-service.md) | Script chain resolution from database ancestry | +| [RequestHistoryService](api-reference/services/request-history-service.md) | Send history persistence, labels, stored response mapping | ### API Reference — Cross-Cutting diff --git a/docs/api-reference/services/collection-service.md b/docs/api-reference/services/collection-service.md index 3cf100c..68bdc56 100644 --- a/docs/api-reference/services/collection-service.md +++ b/docs/api-reference/services/collection-service.md @@ -188,6 +188,17 @@ def rename_request(request_id: int, new_name: str) -> None def delete_request(request_id: int) -> None ``` +### `duplicate_request` + +```python +@staticmethod +def duplicate_request(request_id: int) -> RequestModel +``` + +Clone a request in the same folder, including saved responses and +declarative assertions. The new name is ``{original} Copy`` or +``{original} Copy N`` when siblings already use that pattern. + ### `move_request` ```python diff --git a/docs/api-reference/services/request-history-service.md b/docs/api-reference/services/request-history-service.md new file mode 100644 index 0000000..a427eab --- /dev/null +++ b/docs/api-reference/services/request-history-service.md @@ -0,0 +1,93 @@ +# RequestHistoryService + +Persists HTTP **send history** (metadata in SQLite, bodies and request snapshots +on disk under `user_history_root()`). All methods are `@staticmethod` aliases of +module-level functions. + +Source: `src/services/request_history_service.py` +Repository: `src/database/models/request_history/request_history_repository.py` + +## TypedDicts + +### `RequestHistoryEntryDict` + +Metadata row plus optional loaded file payloads (`get_entry`). + +| Field | Type | Notes | +|-------|------|--------| +| `id` | `int` | Primary key | +| `executed_at` | `str` | ISO timestamp (UTC) | +| `request_id` | `int \| None` | `NULL` after request delete or draft sends | +| `was_persisted_request` | `bool` | `True` if send was from a saved request at record time | +| `request_name` | `str` | Name at send time (not updated on rename) | +| `method`, `url` | `str` | As sent | +| `status_code`, `elapsed_ms` | | Response metadata | +| `error` | `str \| None` | Transport failure | +| `response_headers` | `list \| dict \| None` | Stored headers | +| `body` | `bytes \| None` | Loaded response body (`get_entry` only) | +| `original_request` | `dict \| None` | Loaded snapshot (`get_entry` only) | +| `source_label` | `str \| None` | UI only: `(deleted)`, `(draft)`, or `None` | + +### `source_label` rules + +| Condition | Label | +|-----------|--------| +| `request_id` set | `None` | +| `request_id` is `None` and `was_persisted_request` | `(deleted)` | +| `request_id` is `None` and not `was_persisted_request` | `(draft)` | + +### `SendIdentityDict` + +Captured at send start: `request_id`, `request_name`, `method`, `url`. + +### `HistorySendPayloadDict` + +Built for HTTP replay from a snapshot (`build_send_payload_from_entry`). + +## Methods + +| Method | Purpose | +|--------|---------| +| `gather_send_identity(ctx, editor, data)` | Identity at send start | +| `record_send(identity, response, original_request, settings)` | Persist one send; return entry id | +| `list_for_sidebar(search?, executed_from?, executed_to?)` | Global list (left rail), newest first, limit 500; optional local-calendar date bounds | +| `list_for_request(request_id, search?, executed_from?, executed_to?)` | Per-request list (right rail), limit 200; optional date bounds | +| `get_entry(entry_id)` | Full row + body + snapshot | +| `entry_to_detail_snapshot(entry)` | Read-only sidebar detail panes | +| `entry_to_http_response_dict(entry)` | Dict for `ResponseViewer.load_stored_response` | +| `build_replay_request_dict(entry)` | Editor `load_request` shape from snapshot | +| `build_send_payload_from_entry(entry)` | Worker payload for HTTP replay | +| `can_replay_entry(entry)` | Whether URL exists in snapshot | +| `entry_for_replay(entry_id)` | `get_entry` when replayable | +| `delete_entry(entry_id)` | Remove row and files | +| `replay_source_link_text(entry)` | Response viewer replay banner label | + +## `entry_to_http_response_dict` + +Maps a full history entry to the dict shape expected by +`ResponseViewer.load_stored_response` (not the same as `entry_to_detail_snapshot`). + +| Field | Source | +|-------|--------| +| `status_code`, `elapsed_ms`, `error` | Row metadata | +| `status_text` | HTTP reason phrase (e.g. `"OK"`, `"I'm a teapot"`) | +| `headers` | Normalized `response_headers` list | +| `body` | Decoded bytes, missing-file placeholder, truncation note | +| `size_bytes` | `response_size_bytes` | +| `request_method`, `request_url` | Row `method` / `url` | +| `request_headers` | From snapshot `sent_headers` or header rows | + +On error sends, returns `error`, timing, and request metadata only. + +## Stored vs live response display + +- **`ResponseViewer.load_response`** — live send; enables Save Response. +- **`ResponseViewer.load_stored_response`** — history open/replay display; clears + test/pre-request tabs; does **not** set `_last_live_response`. + +Global open and per-request replay-from-snapshot use **stored** mode. + +## Settings + +`HistorySettingsManager` (`history/*` QSettings): retention, per-day caps, +`save_responses`, `max_response_bytes`. diff --git a/docs/api-reference/signals.md b/docs/api-reference/signals.md index c86b9cb..d14fa4b 100644 --- a/docs/api-reference/signals.md +++ b/docs/api-reference/signals.md @@ -20,6 +20,7 @@ Source: `ui/collections/tree/collection_tree.py` | `new_script_clicked` (popup) | `str, str` | Language code, module_format | | `new_script_requested` (header) | `object, str, str` | Parent folder ID or `None`, language, module_format | | `request_delete_requested` | `int` | Request ID | +| `request_duplicate_requested` | `int` | Request ID to clone | | `request_moved` | `int, int` | Request ID, new collection ID | | `collection_moved` | `int, object` | Collection ID, new parent ID (int or None) | | `new_collection_requested` | `object` | Parent ID (int or None) | diff --git a/docs/api-reference/typedicts.md b/docs/api-reference/typedicts.md index 2819d61..fc8fd50 100644 --- a/docs/api-reference/typedicts.md +++ b/docs/api-reference/typedicts.md @@ -46,6 +46,16 @@ Full saved-response payload used by the sidebar UI. | `created_at` | `str \| None` | ISO 8601 timestamp | | `body_size` | `int` | Body size in bytes | +### RequestHistoryEntryDict + +**Module:** `services/request_history_service.py` +**Full reference:** [RequestHistoryService](services/request-history-service.md) + +Send-history row (metadata plus optional loaded `body` / `original_request`). +Includes `was_persisted_request` for `(deleted)` vs `(draft)` labels when +`request_id` is null. See also `SendIdentityDict` and `HistorySendPayloadDict` +in the same module. + ### VariableDetail **Module:** `services/environment_service.py` diff --git a/docs/ui-reference/collections.md b/docs/ui-reference/collections.md index fa2537a..3ffde02 100644 --- a/docs/ui-reference/collections.md +++ b/docs/ui-reference/collections.md @@ -81,7 +81,7 @@ Stored on each `QTreeWidgetItem`: ### Context Menus (_TreeActionsMixin) -**Request context menu:** Open, Rename (F2), Delete +**Request context menu:** Open, Rename (F2), Duplicate, Delete **Folder context menu:** Overview, Add request, Add folder, Expand all, Collapse all, Rename, Delete diff --git a/docs/ui-reference/main-window.md b/docs/ui-reference/main-window.md index fd34ac9..de76186 100644 --- a/docs/ui-reference/main-window.md +++ b/docs/ui-reference/main-window.md @@ -128,7 +128,7 @@ Environment variable resolution and sidebar refresh. | Method | Description | |--------|-------------| -| `_refresh_variable_map(editor, request_id, local_overrides)` | Push combined variables to editor | +| `_refresh_variable_map(editor, request_id, local_overrides, collection_id=?)` | Push combined variables to editor; pass `collection_id` for orphan/draft tabs (`variable_collection_id`) | | `_on_environment_changed(env_id)` | Refresh all open editor variable maps | | `_on_environments_data_changed()` | After edits in the **Environments** tab: refresh env selector, variable maps, sidebar | | `_on_variable_updated(var_name, new_value, source, source_id)` | Persist global variable change | diff --git a/docs/ui-reference/response-viewer.md b/docs/ui-reference/response-viewer.md index 69209c0..f459c3f 100644 --- a/docs/ui-reference/response-viewer.md +++ b/docs/ui-reference/response-viewer.md @@ -63,7 +63,9 @@ Status badge colour follows HTTP status ranges: | Method | Description | |--------|-------------| | `load_response(data)` | Populate from `HttpResponseDict` | -| `show_loading()` | Show progress bar | +| `show_loading()` | Show progress bar; clears stored-history body when no live response | +| `load_stored_response(data)` | History open display; no Save Response; clears script tabs | +| Pretty / JSON body | Raw text shown immediately; pretty-print runs on a thread pool | | `show_error(msg)` | Display error state | | `set_variable_map(variables)` | Propagate to body editor | | `load_test_results(results)` | Populate Test Results tab from `list[TestResult]` | diff --git a/docs/ui-reference/sidebar.md b/docs/ui-reference/sidebar.md index 4dd0cf6..7c18be2 100644 --- a/docs/ui-reference/sidebar.md +++ b/docs/ui-reference/sidebar.md @@ -1,9 +1,10 @@ # Sidebar Icon rails with collapsible flyout panels: **LeftSidebar** hosts the -collections and environment picker on one stacked page and **Local scripts & -snippets** on another (Phosphor **code** icon); **RightSidebar** hosts variables, -snippets, and saved responses. +collections and environment picker on one stacked page, **Local scripts & +snippets** on another (Phosphor **code** icon), and workspace **History** on a +third (Phosphor **clock-counter-clockwise**); **RightSidebar** hosts variables, +snippets, saved responses, and per-request History. Source: `src/ui/sidebar/` @@ -34,7 +35,8 @@ When open, the flyout uses a **left** border only (vs the rail); the **right** edge is the main horizontal splitter handle only, so there is no double line beside the editor. Page 0 is installed via :meth:`LeftSidebar.set_content`; page 1 via :meth:`LeftSidebar.set_local_scripts_panel` (which also reveals the second -rail icon). +rail icon); page 2 via :meth:`LeftSidebar.set_history_panel` (workspace send +history, ``objectName`` ``globalHistoryPanel``). ### Rail Buttons @@ -42,6 +44,7 @@ rail icon). |--------|-----------------|-------| | Collections | `files` | Collections tree + environment rows (``_left_nav_splitter``) | | Local scripts & snippets | `code` | Local scripts tree + ``SnippetsSidebarPanel`` (vertical splitter) | +| History | `clock-counter-clockwise` | ``HistoryPanel`` in global mode — all sends; Open navigates to editor | ### Signals @@ -283,44 +286,90 @@ Filter by name and search within response bodies. ## HistoryPanel -Read-only list/detail flyout for HTTP sends recorded for the **active saved -request** (not draft tabs). +Read-only list/detail UI for HTTP **send history**. The same widget class is +used twice: -Source: `src/ui/sidebar/history/` +| Instance | `objectName` | Rail | Data source | +|----------|--------------|------|-------------| +| Per-request | `requestHistoryPanel` | Right (4th button) | `RequestHistoryService.list_for_request(request_id)` | +| Workspace global | `globalHistoryPanel` | Left (3rd button) | `RequestHistoryService.list_for_sidebar()` | + +Source: `src/ui/sidebar/history/` (`panel.py`, `global_mode/`). + +See also [RequestHistoryService](../api-reference/services/request-history-service.md). ### objectNames -| Widget | objectName | -|--------|------------| -| Panel | `requestHistoryPanel` | -| Search field | `requestHistorySearch` | -| Tree | `requestHistoryTree` | -| Replay | `requestHistoryReplayButton` | - -Refresh uses the flyout title-bar icon (same row as the panel close button), not -a control inside the panel body. - -### Behaviour - -- Persisted request: `RequestHistoryService.list_for_request(request_id)`; a - `QTreeWidget` groups sends under local calendar days (Today, Yesterday, or a - formatted date) with expand/collapse like the collections tree. -- `requestHistorySearch` filters by URL/name/method substring and exact HTTP status - when the term is all digits (e.g. `200`, `400`). -- Draft tab: empty state — save the request first (does not list global draft sends) -- Detail tabs: Body, Headers, Request Headers, Request Body (from stored snapshot) -- `refresh_requested` → `refresh()` reloads metadata; full bodies loaded on selection -- **Replay** (white icon, top-right of the detail header): sends the stored snapshot over - the network and updates the centre **response** pane only — the request editor - (URL, params, headers, body) is left unchanged. Context menu on send rows: - **Replay this request**, **Delete item**. -- After a replay completes, the **response viewer** shows a `responseReplayIndicator` - pill (left of the status badge) with a link to open this panel and select the - **source** send that was replayed. +| Widget | objectName | Mode | +|--------|------------|------| +| Panel (request) | `requestHistoryPanel` | Right rail | +| Panel (global) | `globalHistoryPanel` | Left rail | +| Global header row | `globalHistoryHeader` | Left rail only (title + date filter + refresh) | +| Date filter button | `iconButton` (funnel) | Global header; per-request search row | +| Date filter popup | `infoPopup` | From/To pickers, presets (Today, 7/30 days, All), Apply, Clear | +| Date editors | `historyDateFilterEdit` | Inside the filter popup | +| Search field | `requestHistorySearch` | Both | +| List stack | `requestHistoryList` | Both | +| Tree | `requestHistoryTree` | Both | +| Replay | `requestHistoryReplayButton` | Request mode only | +| Open | `requestHistoryOpenButton` | Unused in UI (hidden); global open is single-click on tree | + +**Refresh:** On the right, `RightSidebar` reparents `refresh_button()` into the +flyout title bar. On the left, refresh stays in `globalHistoryHeader`. + +### Shared behaviour + +- `QTreeWidget` groups sends under local calendar days (Today, Yesterday, or a + formatted date). +- `requestHistorySearch` filters by URL, request name, and method (substring) and + by **exact** HTTP status when the term is all digits (e.g. `200`, `400` — `40` + does not match `400`). +- **Date range** (funnel): inline popup with local-calendar From/To dates and + presets; filters at query time via `executed_from` / `executed_to` on + `list_for_sidebar` / `list_for_request`. Active filter shows a checked funnel + and a summary tooltip. +- `refresh_requested` → `refresh()` reloads metadata. + +### Per-request detail (right rail only) + +- Detail tabs: Body, Headers, Request Headers, Request Body (from stored snapshot). +- Full payloads load on selection in **request** mode only. +- Row meta may include `(deleted)` or `(draft)` when `request_id` is null (see + `was_persisted_request` in the service docs). + +### Request mode (right rail) + +- Active **saved** request tab only; draft tabs disable the rail icons with a tooltip + (“save the request first”, or “original request was deleted” for tabs opened from + deleted-request history). +- Empty copy: no sends yet for this request. +- **Replay** (detail header): HTTP from stored snapshot; updates centre response only. +- Context menu: **Replay this request**, **Delete item**. +- `replay_requested` / `delete_requested` → `MainWindow` handlers. +- After replay, `responseReplayIndicator` links back to this panel (`focus_entry`). + +### Global mode (left rail) + +- **List only** — no Body/Headers detail stack on the left (preview lives on the + right History panel after **Open**). +- Lists all workspace sends (including draft-tab sends and orphaned rows after + request delete). Default cap: 500 rows (search applies the same limit). +- Empty copy: no send history yet (with hint to send a request). +- **Open** on **single-click** of a send row (also context menu and **Enter**): + emits `entry_open_requested(entry_id)` → `MainWindow._open_from_global_history`. + - **Existing request** (`request_id` set and still in DB): open/focus request tab, + open right History, select the send, then load detail asynchronously (loading bar). + Centre response viewer is unchanged. + - **Deleted / orphan / draft history row**: new draft tab prefilled from snapshot, + stored response in viewer; right History and Saved Responses stay disabled (hover + the greyed rail icons for why). +- No replay/delete on the global instance (use the right panel after navigating). ### Signals -| Signal | Parameters | Description | -|--------|------------|-------------| -| `refresh_requested` | *(none)* | Reload list for current request | -| `replay_requested` | `entry_id: int` | Replay the selected send (response pane only) | +| Signal | Parameters | Mode | Description | +|--------|------------|------|-------------| +| `refresh_requested` | *(none)* | Both | Reload list for current scope | +| `entry_open_requested` | `entry_id: int` | Global | Open send in editor + right History | +| `replay_requested` | `entry_id: int` | Request | Replay selected send | +| `delete_requested` | `entry_id: int` | Request | Delete selected send | diff --git a/src/AGENTS.md b/src/AGENTS.md index 00c16af..5787f5a 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -90,11 +90,21 @@ RequestEditorWidget ──_on_fetch_schema──► SchemaFetchWorker (QThread - `RequestHistoryService` (`request_history_service.py`) persists HTTP **send** history: `gather_send_identity` at send start, `record_send` at the end of `on_send_finished` (skipped when `_suppress_history_record` is set during - debug replay). Settings come from `HistorySettingsManager` (`history/*` - QSettings). Bodies and request snapshots live under `user_history_root()`; - metadata in `request_history_entries`. **Replay** uses - `build_send_payload_from_entry` (snapshot + `sent_headers`; no editor auth); - `delete_entry` removes row and payload files. + debug replay); refreshes `_global_history_panel` always and + `_request_history_panel` when the active tab matches the recorded request. + Settings: `HistorySettingsManager` (`history/*` QSettings). Bodies and + snapshots under `user_history_root()`; metadata in `request_history_entries`. + **Lists:** `list_for_sidebar` (left global rail), `list_for_request` (right + per-request). **Labels:** `was_persisted_request` drives `source_label` + `(deleted)` vs `(draft)` when `request_id` is null. **Display:** + Orphan/deleted rows: draft tab opens instantly from metadata; full payload loads + on a worker thread (`history_navigation/orphan_open.py`). ``TabContext.variable_collection_id`` + restores collection variables/auth when ``request_id`` is ``None``. + `entry_to_http_response_dict` → `ResponseViewer.load_stored_response` (not + live/saveable). **Replay:** `build_send_payload_from_entry`; `delete_entry` + removes row and files. **Global open:** `_HistoryNavigationMixin` + (`history_navigation/`) — `entry_open_requested` → `_open_from_global_history` + (existing tab + right `focus_entry` only; centre `load_stored_response` for orphans). - `ScriptService` and `ScriptEngine` also follow the `@staticmethod` pattern. `ScriptService.build_script_chain(request_id)` walks the ancestor chain to collect inherited scripts. `ScriptEngine` dispatches @@ -443,6 +453,10 @@ Key signals to know (always-on summary): when those sections are not materialised yet. - `ResponseViewerWidget.save_response_requested(dict)` → saves the current live response. - `ResponseViewerWidget.save_availability_changed(bool)` → refreshes right-sidebar saved-response affordances. +- `ResponseViewerWidget.load_stored_response(dict)` → history/replay display from + `entry_to_http_response_dict`; clears test/pre-request tabs, disables Save Response, + does not set `_last_live_response`. `show_loading()` discards stored body when no + live response is loaded. - `SavedResponsesPanel` emits `save_current_requested`, `rename_requested`, `duplicate_requested`, and `delete_requested` — all handled in `MainWindow` through `CollectionService`. @@ -594,7 +608,9 @@ into `%Y-%m-%d %H:%M` strings for the UI. value goes into `local_overrides`. They are merged on top of the combined variable map in `MainWindow._refresh_variable_map()` and tagged with `is_local=True` in `VariableDetail` so the popup can show - Update/Reset buttons. + Update/Reset buttons. For draft tabs from deleted-request history, + pass `collection_id=ctx.variable_collection_id` (same as + `_refresh_sidebar`) so Auth/URL fields resolve collection variables. 7. **`TabContext.draft_name` tracks the display name of unsaved tabs** — Set to `"Untitled Request"` when a draft tab is opened. Updated when the user renames via the breadcrumb bar. Used as fallback label in the diff --git a/src/database/models/collections/collection_query_repository.py b/src/database/models/collections/collection_query_repository.py index a139298..fda1d63 100644 --- a/src/database/models/collections/collection_query_repository.py +++ b/src/database/models/collections/collection_query_repository.py @@ -280,6 +280,12 @@ def get_request_variable_chain_detailed(request_id: int) -> dict[str, tuple[str, return merged +def get_collection_variable_chain(collection_id: int) -> dict[str, str]: + """Walk the parent chain from *collection_id* and merge collection variables.""" + detailed = get_collection_variable_chain_detailed(collection_id) + return {key: value for key, (value, _coll_id) in detailed.items()} + + def get_collection_variable_chain_detailed( collection_id: int, ) -> dict[str, tuple[str, int]]: diff --git a/src/database/models/collections/collection_repository.py b/src/database/models/collections/collection_repository.py index 891de47..c3f3d7e 100644 --- a/src/database/models/collections/collection_repository.py +++ b/src/database/models/collections/collection_repository.py @@ -9,10 +9,11 @@ from __future__ import annotations +import copy import logging from typing import Any -from sqlalchemy import update +from sqlalchemy import select, update from database.database import get_session @@ -132,6 +133,91 @@ def create_new_request( return new_request +def unique_duplicate_request_name(base_name: str, existing_names: set[str]) -> str: + """Return ``{base_name} Copy`` or ``{base_name} Copy N`` unique in *existing_names*.""" + first = f"{base_name} Copy" + if first not in existing_names: + return first + n = 2 + while True: + candidate = f"{base_name} Copy {n}" + if candidate not in existing_names: + return candidate + n += 1 + + +def duplicate_request(request_id: int) -> RequestModel: + """Deep-copy a request, its saved responses, and assertions into the same folder.""" + from database.models.request_assertions.model.request_assertion_model import ( + RequestAssertionModel, + ) + + from .model.saved_response_model import SavedResponseModel + + with get_session() as session: + source = session.get(RequestModel, request_id) + if source is None: + raise ValueError(f"No request found with id={request_id}") + + sibling_names = { + row[0] + for row in session.execute( + select(RequestModel.name).where(RequestModel.collection_id == source.collection_id) + ).all() + } + new_name = unique_duplicate_request_name(source.name, sibling_names) + + duplicate = RequestModel( + collection_id=source.collection_id, + name=new_name, + method=source.method, + url=source.url, + body=source.body, + request_parameters=copy.deepcopy(source.request_parameters), + headers=copy.deepcopy(source.headers), + description=source.description, + body_mode=source.body_mode, + body_options=copy.deepcopy(source.body_options), + auth=copy.deepcopy(source.auth), + scripts=copy.deepcopy(source.scripts), + settings=copy.deepcopy(source.settings), + events=copy.deepcopy(source.events), + protocol_profile_behavior=copy.deepcopy(source.protocol_profile_behavior), + ) + session.add(duplicate) + session.flush() + + for saved in list(source.saved_responses): + session.add( + SavedResponseModel( + request_id=duplicate.id, + name=saved.name, + status=saved.status, + code=saved.code, + headers=copy.deepcopy(saved.headers), + body=saved.body, + preview_language=saved.preview_language, + original_request=copy.deepcopy(saved.original_request), + ) + ) + + for assertion in list(source.assertions): + session.add( + RequestAssertionModel( + request_id=duplicate.id, + subject=assertion.subject, + operator=assertion.operator, + expected=assertion.expected, + enabled=assertion.enabled, + order_index=assertion.order_index, + ) + ) + + session.flush() + session.refresh(duplicate) + return duplicate + + def delete_request(request_id: int) -> None: """Delete the request identified by *request_id*.""" from database.models.request_history import request_history_repository diff --git a/src/database/models/request_history/request_history_repository.py b/src/database/models/request_history/request_history_repository.py index 4900168..3e66912 100644 --- a/src/database/models/request_history/request_history_repository.py +++ b/src/database/models/request_history/request_history_repository.py @@ -4,7 +4,7 @@ import logging from collections import defaultdict -from datetime import UTC, date, datetime, timedelta +from datetime import UTC, date, datetime, time, timedelta from typing import Any from sqlalchemy import or_, select @@ -24,6 +24,38 @@ def local_date(executed_at: datetime) -> date: return executed_at.astimezone().date() +def _utc_bounds_for_local_dates( + executed_from: date | None, + executed_to: date | None, +) -> tuple[datetime | None, datetime | None]: + """Return inclusive-start and exclusive-end UTC bounds for local calendar days.""" + tz = datetime.now().astimezone().tzinfo + start_utc: datetime | None = None + end_utc: datetime | None = None + if executed_from is not None: + start_local = datetime.combine(executed_from, time.min, tzinfo=tz) + start_utc = start_local.astimezone(UTC) + if executed_to is not None: + end_local = datetime.combine(executed_to + timedelta(days=1), time.min, tzinfo=tz) + end_utc = end_local.astimezone(UTC) + return start_utc, end_utc + + +def _apply_executed_range( + stmt: Any, + *, + executed_from: date | None = None, + executed_to: date | None = None, +) -> Any: + """Restrict *stmt* to rows whose ``executed_at`` falls in the local date range.""" + start_utc, end_utc = _utc_bounds_for_local_dates(executed_from, executed_to) + if start_utc is not None: + stmt = stmt.where(RequestHistoryEntryModel.executed_at >= start_utc) + if end_utc is not None: + stmt = stmt.where(RequestHistoryEntryModel.executed_at < end_utc) + return stmt + + def _entry_to_dict(row: RequestHistoryEntryModel) -> dict[str, Any]: """Convert an ORM row to a plain dict (metadata only).""" executed = row.executed_at @@ -136,28 +168,48 @@ def delete_entry(entry_id: int) -> bool: return True -def get_entry(entry_id: int) -> dict[str, Any] | None: - """Load one entry with body bytes and request snapshot attached.""" +def get_entry_metadata(entry_id: int) -> dict[str, Any] | None: + """Load one history row from the database without reading on-disk payloads.""" with get_session() as session: row = session.get(RequestHistoryEntryModel, entry_id) if row is None: return None - data = _entry_to_dict(row) + return _entry_to_dict(row) + + +def get_entry(entry_id: int) -> dict[str, Any] | None: + """Load one entry with body bytes and request snapshot attached.""" + data = get_entry_metadata(entry_id) + if data is None: + return None body_bytes = body_store.read_body(data.get("response_body_path")) data["body"] = body_bytes data["original_request"] = body_store.read_request_snapshot(data.get("request_snapshot_path")) return data -def list_entries_for_sidebar(*, search: str = "", limit: int = 500) -> list[dict[str, Any]]: - """Return metadata rows newest-first; optional SQL search over all retained rows.""" +def list_entries_for_sidebar( + *, + search: str = "", + limit: int = 500, + executed_from: date | None = None, + executed_to: date | None = None, +) -> list[dict[str, Any]]: + """Return metadata rows newest-first; optional search and local date range.""" term = search.strip() with get_session() as session: stmt = select(RequestHistoryEntryModel).order_by( RequestHistoryEntryModel.executed_at.desc(), RequestHistoryEntryModel.id.desc(), ) - stmt = _apply_history_search(stmt, term) if term else stmt.limit(limit) + if term: + stmt = _apply_history_search(stmt, term) + stmt = _apply_executed_range( + stmt, + executed_from=executed_from, + executed_to=executed_to, + ) + stmt = stmt.limit(limit) rows = list(session.execute(stmt).scalars().all()) return [_entry_to_dict(row) for row in rows] @@ -180,6 +232,8 @@ def list_for_request( *, search: str = "", limit: int = 200, + executed_from: date | None = None, + executed_to: date | None = None, ) -> list[dict[str, Any]]: """Return metadata rows for one saved request, newest first.""" term = search.strip() @@ -192,7 +246,14 @@ def list_for_request( RequestHistoryEntryModel.id.desc(), ) ) - stmt = _apply_history_search(stmt, term) if term else stmt.limit(limit) + if term: + stmt = _apply_history_search(stmt, term) + stmt = _apply_executed_range( + stmt, + executed_from=executed_from, + executed_to=executed_to, + ) + stmt = stmt.limit(limit) rows = list(session.execute(stmt).scalars().all()) return [_entry_to_dict(row) for row in rows] diff --git a/src/services/collection_service.py b/src/services/collection_service.py index 5a0abb4..30005aa 100644 --- a/src/services/collection_service.py +++ b/src/services/collection_service.py @@ -32,6 +32,7 @@ delete_collection, delete_request, delete_saved_response, + duplicate_request, duplicate_saved_response, rename_collection, rename_request, @@ -343,6 +344,18 @@ def delete_request(request_id: int) -> None: delete_request(request_id) logger.info("Deleted request id=%s", request_id) + @staticmethod + def duplicate_request(request_id: int) -> RequestModel: + """Clone a request (including saved responses and assertions) in the same folder.""" + result = duplicate_request(request_id) + logger.info( + "Duplicated request id=%s -> id=%s name=%r", + request_id, + result.id, + result.name, + ) + return result + @staticmethod def move_request(request_id: int, new_collection_id: int) -> None: """Move a request to a different collection.""" diff --git a/src/services/environment_service.py b/src/services/environment_service.py index e396d69..5a8d93f 100644 --- a/src/services/environment_service.py +++ b/src/services/environment_service.py @@ -176,16 +176,21 @@ def build_variable_map(environment_id: int | None) -> dict[str, str]: def build_combined_variable_map( environment_id: int | None, request_id: int | None, + *, + collection_id: int | None = None, ) -> dict[str, str]: """Build a merged variable map from collection and environment. Collection variables are inherited upward from the request's - parent chain. Environment variables take precedence over - collection variables when keys overlap. + parent chain (or from *collection_id* when *request_id* is absent, + e.g. a draft tab opened from deleted-request history). + Environment variables take precedence over collection variables + when keys overlap. Returns an empty dict if neither source provides variables. """ from database.models.collections.collection_query_repository import ( + get_collection_variable_chain, get_request_variable_chain, ) @@ -193,6 +198,8 @@ def build_combined_variable_map( variables: dict[str, str] = {} if request_id is not None: variables = get_request_variable_chain(request_id) + elif collection_id is not None: + variables = get_collection_variable_chain(collection_id) # 2. Environment variables override collection variables env_vars = EnvironmentService.build_variable_map(environment_id) @@ -204,6 +211,8 @@ def build_combined_variable_map( def build_combined_variable_detail_map( environment_id: int | None, request_id: int | None, + *, + collection_id: int | None = None, ) -> dict[str, VariableDetail]: """Build a merged variable map with source metadata. @@ -213,6 +222,7 @@ def build_combined_variable_detail_map( Environment variables take precedence over collection variables. """ from database.models.collections.collection_query_repository import ( + get_collection_variable_chain_detailed, get_request_variable_chain_detailed, ) @@ -220,12 +230,17 @@ def build_combined_variable_detail_map( # 1. Collection-level variables (inherited up the tree) if request_id is not None: - for key, (value, coll_id) in get_request_variable_chain_detailed(request_id).items(): - details[key] = { - "value": value, - "source": "collection", - "source_id": coll_id, - } + chain = get_request_variable_chain_detailed(request_id) + elif collection_id is not None: + chain = get_collection_variable_chain_detailed(collection_id) + else: + chain = {} + for key, (value, coll_id) in chain.items(): + details[key] = { + "value": value, + "source": "collection", + "source_id": coll_id, + } # 2. Environment variables override collection variables if environment_id is not None: diff --git a/src/services/request_history_service.py b/src/services/request_history_service.py index 452bf3f..63b7cfd 100644 --- a/src/services/request_history_service.py +++ b/src/services/request_history_service.py @@ -3,6 +3,8 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import date +from http import HTTPStatus from typing import Any, TypedDict, cast from database.models.request_history import request_history_repository @@ -25,6 +27,7 @@ class RequestHistoryEntryDict(TypedDict, total=False): id: int executed_at: str request_id: int | None + was_persisted_request: bool request_name: str method: str url: str @@ -94,17 +97,56 @@ def _body_bytes_from_response(data: dict[str, Any]) -> bytes | None: return str(body).encode("utf-8", errors="replace") -def _source_label(request_id: int | None, request_name: str) -> str | None: - """Return a muted UI label for unattached rows (metadata only). +def _source_label(request_id: int | None, was_persisted_request: bool) -> str | None: + """Return a muted UI label for unattached rows (metadata only).""" + if request_id is not None: + return None + if was_persisted_request: + return "(deleted)" + return "(draft)" + + +def _normalize_history_response_headers( + raw: list[Any] | dict[str, Any] | None, +) -> list[dict[str, str]]: + """Convert stored history headers to the list shape expected by the response viewer.""" + if isinstance(raw, dict): + return [{"key": str(key), "value": str(value)} for key, value in raw.items()] + if not isinstance(raw, list): + return [] + out: list[dict[str, str]] = [] + for row in raw: + if isinstance(row, dict): + out.append( + { + "key": str(row.get("key", "")), + "value": str(row.get("value", "")), + } + ) + return out - Rows with ``request_id is NULL`` are either unsaved-tab sends or orphaned - after the collection request was deleted; v1 uses ``(deleted)`` when the - id is missing and a name was stored (draft sends are hidden on the - per-request rail). - """ - if request_id is None: - return "(deleted)" if request_name.strip() else "(draft)" - return None + +def _request_headers_for_viewer(snapshot: Mapping[str, Any] | None) -> list[dict[str, str]]: + """Build outgoing header rows for the response viewer Request Headers tab.""" + if not snapshot: + return [] + sent = snapshot.get("sent_headers") + if isinstance(sent, dict): + return [{"key": str(k), "value": str(v)} for k, v in sent.items()] + if isinstance(sent, list): + return _normalize_history_response_headers(sent) + headers = snapshot.get("headers") + if isinstance(headers, list): + rows: list[dict[str, str]] = [] + for row in headers: + if not isinstance(row, dict) or not row.get("enabled", True): + continue + key = str(row.get("key", "")).strip() + if not key: + continue + rows.append({"key": key, "value": str(row.get("value", ""))}) + return rows + return [] def enrich_snapshot_for_history( @@ -175,31 +217,70 @@ def _entries_with_labels(rows: list[dict[str, Any]]) -> list[RequestHistoryEntry for row in rows: entry = cast(RequestHistoryEntryDict, dict(row)) entry["source_label"] = _source_label( - row.get("request_id"), str(row.get("request_name", "")) + row.get("request_id"), + bool(row.get("was_persisted_request")), ) out.append(entry) return out -def list_for_sidebar(search: str = "") -> list[RequestHistoryEntryDict]: +def list_for_sidebar( + search: str = "", + *, + executed_from: date | None = None, + executed_to: date | None = None, +) -> list[RequestHistoryEntryDict]: """List all history metadata (global sidebar; newest first).""" - rows = request_history_repository.list_entries_for_sidebar(search=search, limit=500) + rows = request_history_repository.list_entries_for_sidebar( + search=search, + limit=500, + executed_from=executed_from, + executed_to=executed_to, + ) return _entries_with_labels(rows) -def list_for_request(request_id: int, search: str = "") -> list[RequestHistoryEntryDict]: +def list_for_request( + request_id: int, + search: str = "", + *, + executed_from: date | None = None, + executed_to: date | None = None, +) -> list[RequestHistoryEntryDict]: """List send history for one persisted request.""" - rows = request_history_repository.list_for_request(request_id, search=search, limit=200) + rows = request_history_repository.list_for_request( + request_id, + search=search, + limit=200, + executed_from=executed_from, + executed_to=executed_to, + ) return _entries_with_labels(rows) +def get_entry_metadata(entry_id: int) -> RequestHistoryEntryDict | None: + """Load database metadata for a history row (no body/snapshot file reads).""" + row = request_history_repository.get_entry_metadata(entry_id) + if row is None: + return None + entry = cast(RequestHistoryEntryDict, dict(row)) + entry["source_label"] = _source_label( + row.get("request_id"), + bool(row.get("was_persisted_request")), + ) + return entry + + def get_entry(entry_id: int) -> RequestHistoryEntryDict | None: """Load a full history entry including file payloads.""" row = request_history_repository.get_entry(entry_id) if row is None: return None entry = cast(RequestHistoryEntryDict, dict(row)) - entry["source_label"] = _source_label(row.get("request_id"), str(row.get("request_name", ""))) + entry["source_label"] = _source_label( + row.get("request_id"), + bool(row.get("was_persisted_request")), + ) return entry @@ -301,6 +382,57 @@ def replay_source_link_text(entry: RequestHistoryEntryDict) -> str: return f"View {method}{status_part} ({when})" +def _http_status_reason_phrase(code: int) -> str: + """Return the standard HTTP reason phrase for *code*, or empty if unknown.""" + try: + return HTTPStatus(code).phrase + except ValueError: + return "" + + +def entry_to_http_response_dict(entry: RequestHistoryEntryDict) -> dict[str, Any]: + """Map a full history entry to the dict shape used by :meth:`ResponseViewer.load_stored_response`.""" + err = entry.get("error") + elapsed = float(entry.get("elapsed_ms", 0.0) or 0.0) + snapshot = entry.get("original_request") + snap_dict = snapshot if isinstance(snapshot, dict) else {} + req_method = str(entry.get("method", "") or snap_dict.get("method", "GET")) + req_url = str(entry.get("url", "") or snap_dict.get("url", "")) + req_headers = _request_headers_for_viewer(snap_dict) + + if err: + return { + "error": str(err), + "elapsed_ms": elapsed, + "request_method": req_method, + "request_url": req_url, + "request_headers": req_headers, + } + + code = int(entry.get("status_code", 0) or 0) + body_bytes = entry.get("body") + body_text = "" + if body_bytes: + body_text = body_bytes.decode("utf-8", errors="replace") + elif entry.get("response_size_bytes"): + body_text = "[Response body unavailable — history file missing from storage]" + if entry.get("body_truncated") and body_text and not body_text.startswith("["): + body_text = f"{body_text}\n\n[Response body truncated in history storage]" + + headers = _normalize_history_response_headers(entry.get("response_headers")) + return { + "status_code": code, + "status_text": _http_status_reason_phrase(code), + "elapsed_ms": elapsed, + "headers": headers, + "body": body_text, + "size_bytes": int(entry.get("response_size_bytes", 0) or 0), + "request_method": req_method, + "request_url": req_url, + "request_headers": req_headers, + } + + def entry_to_detail_snapshot(entry: RequestHistoryEntryDict) -> dict[str, Any]: """Shape a history row for read-only detail panes (future sidebar).""" body_bytes = entry.get("body") @@ -335,6 +467,7 @@ class RequestHistoryService: record_send = staticmethod(record_send) list_for_sidebar = staticmethod(list_for_sidebar) list_for_request = staticmethod(list_for_request) + get_entry_metadata = staticmethod(get_entry_metadata) get_entry = staticmethod(get_entry) build_replay_request_dict = staticmethod(build_replay_request_dict) build_send_payload_from_entry = staticmethod(build_send_payload_from_entry) @@ -342,4 +475,5 @@ class RequestHistoryService: can_replay_entry = staticmethod(can_replay_entry) entry_for_replay = staticmethod(entry_for_replay) entry_to_detail_snapshot = staticmethod(entry_to_detail_snapshot) + entry_to_http_response_dict = staticmethod(entry_to_http_response_dict) replay_source_link_text = staticmethod(replay_source_link_text) diff --git a/src/ui/AGENTS.md b/src/ui/AGENTS.md index 2cbf849..37f021c 100644 --- a/src/ui/AGENTS.md +++ b/src/ui/AGENTS.md @@ -272,6 +272,9 @@ standard object names: | `leftSidebarRailButton` | `QToolButton` | Rail icon (``_LeftRailButton``): width ``round(LEFT_RAIL_WIDTH_EM * em)``, icon ``round(LEFT_RAIL_ICON_EM * em)``, height ``icon_size + LEFT_RAIL_BUTTON_EXTRA_HEIGHT_PX``; checked left accent **painted** full height (``LEFT_RAIL_ACCENT_STRIPE_WIDTH_PX``); QSS margin/padding ``0`` | | `sidebarPanelArea` | `QWidget` | Right sidebar collapsible flyout panel (separate splitter child) | | `requestHistoryPanel` | `HistoryPanel` | Per-request History flyout (right rail, 4th button) | +| `globalHistoryPanel` | `HistoryPanel` | Workspace History flyout (left rail, 3rd button; `set_global_mode`) | +| `globalHistoryHeader` | `QWidget` | Left global History title row (History label + refresh) | +| `requestHistoryOpenButton` | `QPushButton` | Hidden; global History opens on tree single-click | | `requestHistorySearch` | `QLineEdit` | Filter History by URL substring or status code (e.g. `200`) | | `requestHistoryList` | `QStackedWidget` | Bordered list area (tree or no-match empty state) | | `requestHistoryTree` | `QTreeWidget` | History tree inside `requestHistoryList` (date groups → sends) | diff --git a/src/ui/collections/collection_widget.py b/src/ui/collections/collection_widget.py index ab9fce3..e02d025 100644 --- a/src/ui/collections/collection_widget.py +++ b/src/ui/collections/collection_widget.py @@ -146,6 +146,8 @@ def __init__(self, parent: QWidget | None = None, *, variant: str = "collections self._tree_widget.script_rename_requested.connect(self._on_script_rename) self._tree_widget.script_rename_requested.connect(self.script_rename_requested.emit) self._tree_widget.request_delete_requested.connect(self._on_request_delete) + if self._tree_kind == "collections": + self._tree_widget.request_duplicate_requested.connect(self._on_request_duplicate) self._tree_widget.request_moved.connect(self._on_request_moved) self._tree_widget.collection_moved.connect(self._on_collection_moved) self._tree_widget.new_collection_requested.connect(self._create_new_collection) @@ -274,6 +276,31 @@ def _on_request_delete(self, request_id: int) -> None: return self._safe_svc_call("delete request", CollectionService.delete_request, request_id) + @Slot(int) + def _on_request_duplicate(self, request_id: int) -> None: + """Clone *request_id*, add it to the tree, and open it in a tab.""" + try: + new_request = CollectionService.duplicate_request(request_id) + except Exception as exc: + logger.error("Failed to duplicate request: %s", exc) + QMessageBox.warning( + self, + "Operation Failed", + f"Failed to duplicate request:\n{exc}", + ) + return + self._tree_widget.add_request( + { + "name": new_request.name, + "url": new_request.url, + "id": new_request.id, + "method": new_request.method, + }, + new_request.collection_id, + ) + self._tree_widget.select_item_by_id(new_request.id, "request") + self.item_action_triggered.emit("request", new_request.id, "Open") + @Slot(int, int) def _on_request_moved(self, request_id: int, new_collection_id: int) -> None: if self._tree_kind == "local_scripts": diff --git a/src/ui/collections/tree/collection_tree.py b/src/ui/collections/tree/collection_tree.py index 362b14d..ecd402b 100644 --- a/src/ui/collections/tree/collection_tree.py +++ b/src/ui/collections/tree/collection_tree.py @@ -63,6 +63,7 @@ class CollectionTree(_TreeActionsMixin, QWidget): int, str, str, str ) # script_id, basename, language, module_format request_delete_requested = Signal(int) # request_id + request_duplicate_requested = Signal(int) # request_id request_moved = Signal(int, int) # request_id, new_collection_id collection_moved = Signal(int, object) # collection_id, new_parent_id new_collection_requested = Signal(object) # parent_id (int | None) diff --git a/src/ui/collections/tree/tree_actions.py b/src/ui/collections/tree/tree_actions.py index e575615..4cf47cb 100644 --- a/src/ui/collections/tree/tree_actions.py +++ b/src/ui/collections/tree/tree_actions.py @@ -69,6 +69,7 @@ class _TreeActionsMixin(_TreeOverlayRenameMixin, _TreeActionsBase): request_rename_requested: Signal script_rename_requested: Signal request_delete_requested: Signal + request_duplicate_requested: Signal new_collection_requested: Signal new_request_requested: Signal run_collection_requested: Signal @@ -91,6 +92,7 @@ def _setup_context_menus(self) -> None: for label, _icon_name in [ ("Open", "arrow-square-out"), ("Rename", "pencil-simple"), + ("Duplicate", "copy"), ("Delete", "trash"), ]: action = self._request_menu.addAction(label) @@ -191,6 +193,8 @@ def _emit_menu_action(self, action: QAction) -> None: if action_name == "Rename": self._handle_rename(item_id, item_type) + elif action_name == "Duplicate" and is_leaf_item_type(item_type) and item_type == "request": + self.request_duplicate_requested.emit(item_id) elif action_name == "Delete": self._handle_delete(item_id, item_type) elif action_name == "Run" and item_type == "folder": diff --git a/src/ui/main_window/draft_controller.py b/src/ui/main_window/draft_controller.py index 591143d..1adabfe 100644 --- a/src/ui/main_window/draft_controller.py +++ b/src/ui/main_window/draft_controller.py @@ -54,6 +54,9 @@ def _on_save_response(self, data: dict) -> None: ... def _on_replay_history_link_clicked(self, entry_id: int) -> None: ... def _sync_save_btn(self, dirty: bool) -> None: ... def _on_editor_dirty_changed(self, dirty: bool) -> None: ... + def _on_editor_request_changed(self, _data: dict | None = None) -> None: ... + def _on_editor_scripts_tab_changed(self, active: bool) -> None: ... + def _on_viewer_save_availability_changed(self, _enabled: bool = False) -> None: ... def _on_tab_changed(self, index: int) -> None: ... def _flush_tab_change(self) -> None: ... def _enforce_tab_limit_before_open(self) -> bool: ... @@ -75,15 +78,18 @@ def _open_folder( # ------------------------------------------------------------------ # Open a new draft request tab # ------------------------------------------------------------------ - def _open_draft_request(self) -> None: + def _open_draft_request(self) -> bool: """Open a new draft request tab that is not yet persisted to the DB. The tab has ``request_id=None`` and is marked dirty immediately so the Save button is enabled. Saving triggers the save-to-collection dialog. + + Returns: + ``True`` when a new draft tab was created and activated. """ if not self._enforce_tab_limit_before_open(): - return + return False data: RequestLoadDict = { "name": _DRAFT_TAB_NAME, @@ -129,8 +135,11 @@ def _open_draft_request(self) -> None: editor.save_requested.connect(self._on_save_request) editor.dirty_changed.connect(self._sync_save_btn) editor.dirty_changed.connect(self._on_editor_dirty_changed) + editor.request_changed.connect(self._on_editor_request_changed) + editor.scripts_tab_active_changed.connect(self._on_editor_scripts_tab_changed) viewer.save_response_requested.connect(self._on_save_response) viewer.replay_history_link_clicked.connect(self._on_replay_history_link_clicked) + viewer.save_availability_changed.connect(self._on_viewer_save_availability_changed) # Mark as dirty so Save button is enabled for the new draft editor._set_dirty(True) @@ -140,6 +149,7 @@ def _open_draft_request(self) -> None: # Flush the debounced heavy work immediately for programmatic opens self._flush_tab_change() self._persist_open_tabs() # type: ignore[attr-defined] + return True # ------------------------------------------------------------------ # Save draft request → save-to-collection dialog diff --git a/src/ui/main_window/history_navigation/__init__.py b/src/ui/main_window/history_navigation/__init__.py new file mode 100644 index 0000000..566c169 --- /dev/null +++ b/src/ui/main_window/history_navigation/__init__.py @@ -0,0 +1,5 @@ +"""MainWindow mixin for opening workspace send history into request tabs.""" + +from ui.main_window.history_navigation.mixin import _HistoryNavigationMixin + +__all__ = ["_HistoryNavigationMixin"] diff --git a/src/ui/main_window/history_navigation/mixin.py b/src/ui/main_window/history_navigation/mixin.py new file mode 100644 index 0000000..8f8c42c --- /dev/null +++ b/src/ui/main_window/history_navigation/mixin.py @@ -0,0 +1,231 @@ +"""Open workspace history entries in request tabs (left-rail global history).""" + +# mypy: disable-error-code=attr-defined + +from __future__ import annotations + +from typing import Any, cast + +from PySide6.QtCore import QTimer +from shiboken6 import Shiboken + +from services.collection_service import CollectionService, RequestLoadDict +from services.request_history_service import RequestHistoryEntryDict, RequestHistoryService +from ui.main_window.history_navigation.orphan_open import OrphanHistoryOpenLoader +from ui.request.navigation.tab_manager import TabContext + + +class _HistoryNavigationMixin: + """Navigate from global send history into editor tabs and right-rail History. + + Expects the host class (``MainWindow``) to provide tab/sidebar APIs. + """ + + _global_history_open_busy: bool + _orphan_history_open_loader: OrphanHistoryOpenLoader + _orphan_open_generation: int + _pending_orphan_open_tab_index: int | None + + def _init_orphan_history_open_loader(self) -> None: + """Wire async loader for opening deleted-request history rows.""" + loader = OrphanHistoryOpenLoader(self) # type: ignore[arg-type] + loader.finished.connect(self._on_orphan_history_open_loaded) + self._orphan_history_open_loader = loader + self._orphan_open_generation = 0 + self._pending_orphan_open_tab_index = None + + def _refresh_global_history_panel(self) -> None: + """Reload the left-rail workspace history list.""" + panel = getattr(self, "_global_history_panel", None) + if panel is not None: + panel.refresh() + + def _open_from_global_history(self, entry_id: int) -> None: + """Queue open on the next event-loop tick so the tree click returns immediately.""" + if getattr(self, "_global_history_open_busy", False): + return + QTimer.singleShot(0, lambda eid=entry_id: self._run_open_from_global_history(eid)) + + def _run_open_from_global_history(self, entry_id: int) -> None: + """Load history entry into editor tabs (runs after the click handler returns).""" + if getattr(self, "_global_history_open_busy", False): + return + self._global_history_open_busy = True + try: + self._open_from_global_history_impl(entry_id) + finally: + self._global_history_open_busy = False + + def _open_from_global_history_impl(self, entry_id: int) -> None: + """Open a history row in the editor and per-request History flyout.""" + meta = RequestHistoryService.get_entry_metadata(entry_id) + if meta is None: + self._show_history_open_status("History entry is no longer available") + return + + request_id = meta.get("request_id") + if request_id is not None: + rid = int(request_id) + if CollectionService.get_request(rid) is not None: + QTimer.singleShot( + 0, + lambda: self._open_existing_request_from_history(rid, entry_id), + ) + return + self._open_orphan_history_as_draft(entry_id) + + def _show_history_open_status(self, message: str) -> None: + """Show a short status-bar message for global history open failures.""" + status = self.statusBar() if hasattr(self, "statusBar") else None + if status is not None: + status.showMessage(message, 5000) + + def _open_existing_request_from_history( + self, + request_id: int, + entry_id: int, + ) -> None: + """Activate a saved request tab and select the send in the right History panel.""" + ctx = self._tab_context_for_request_id(request_id) + if ctx is not None and ctx.is_sending: + self._show_history_open_status("Wait for the current send to finish") + return + + self._open_request(request_id, push_history=True, is_preview=False) # type: ignore[attr-defined] + if self._tab_context_for_request_id(request_id) is None: + self._show_history_open_status("Could not open request tab") + return + QTimer.singleShot( + 0, + lambda: self._finish_open_existing_history(request_id, entry_id), + ) + + def _finish_open_existing_history( + self, + request_id: int, + entry_id: int, + ) -> None: + """Focus the request tab, then load the send detail on the right asynchronously.""" + ctx = self._tab_context_for_request_id(request_id) + if ctx is None or ctx.tab_type != "request" or ctx.request_id != request_id: + self._show_history_open_status("Could not open request tab") + return + if ctx.is_sending: + self._show_history_open_status("Wait for the current send to finish") + return + self._refresh_sidebar(history_load_detail=False) # type: ignore[attr-defined] + self._right_sidebar.open_panel("request_history") + panel = getattr(self, "_request_history_panel", None) + if panel is not None: + QTimer.singleShot(0, lambda: panel.schedule_detail_load(entry_id)) + + def _open_orphan_history_as_draft(self, entry_id: int) -> None: + """Open a deleted-request history send in a draft tab (async full load).""" + meta = RequestHistoryService.get_entry_metadata(entry_id) + if meta is None: + self._show_history_open_status("History entry is no longer available") + return + if not self._open_draft_request(): # type: ignore[attr-defined] + self._show_history_open_status("Could not open request tab") + return + idx = self._tab_bar.currentIndex() # type: ignore[attr-defined] + ctx = self._tabs.get(idx) + if ctx is None or ctx.request_id is not None: + self._show_history_open_status("Could not open request tab") + return + + name = str(meta.get("request_name", "") or "Untitled Request") + method = str(meta.get("method", "GET") or "GET") + url = str(meta.get("url", "") or "") + ctx.draft_name = name + ctx.variable_collection_id = None + editor = ctx.require_editor() + editor.load_request( + cast(RequestLoadDict, {"method": method, "url": url, "name": name}), + request_id=None, + ) + editor._set_dirty(True) + self._tab_bar.update_tab(idx, method=method, name=name) # type: ignore[attr-defined] + self._breadcrumb_bar.set_path( # type: ignore[attr-defined] + [{"name": name, "type": "request", "id": 0}] + ) + viewer = ctx.response_viewer + if viewer is not None: + viewer.show_loading() + + self._orphan_history_open_loader.cancel() + self._orphan_open_generation += 1 + generation = self._orphan_open_generation + self._pending_orphan_open_tab_index = idx + self._orphan_history_open_loader.load(entry_id, generation) + + def _on_orphan_history_open_loaded(self, generation: int, payload: object) -> None: + """Apply async history payload to the draft tab opened for an orphan row.""" + if generation != self._orphan_open_generation: + return + idx = self._pending_orphan_open_tab_index + self._pending_orphan_open_tab_index = None + if idx is None: + return + ctx = self._tabs.get(idx) # type: ignore[attr-defined] + if ctx is None or ctx.request_id is not None: + return + entry_raw = payload.get("entry") if isinstance(payload, dict) else None + if not isinstance(entry_raw, dict) or entry_raw.get("id") is None: + self._show_history_open_status("History entry is no longer available") + viewer = ctx.response_viewer + if viewer is not None: + viewer.show_error("History entry is no longer available") + return + http_raw = payload.get("http") if isinstance(payload, dict) else None + http_data = http_raw if isinstance(http_raw, dict) else None + entry = cast(RequestHistoryEntryDict, entry_raw) + QTimer.singleShot( + 0, + lambda c=ctx, e=entry, h=http_data: self._apply_orphan_history_to_tab(c, e, h), + ) + + def _apply_orphan_history_to_tab( + self, + ctx: TabContext, + entry: RequestHistoryEntryDict, + http_response: dict[str, Any] | None, + ) -> None: + """Populate draft tab editor and response viewer from a full history entry.""" + editor = ctx.editor + if editor is None or not Shiboken.isValid(editor): + return + viewer = ctx.response_viewer + snapshot = RequestHistoryService.build_replay_request_dict(entry) + coll_raw = snapshot.get("collection_id") + if isinstance(coll_raw, int): + ctx.variable_collection_id = coll_raw + else: + ctx.variable_collection_id = None + editor.load_request(cast(RequestLoadDict, snapshot), request_id=None) + editor._set_dirty(True) + name = str(entry.get("request_name", "") or snapshot.get("name", "") or "Untitled Request") + ctx.draft_name = name + method = str(snapshot.get("method", "GET") or "GET") + idx = self._tab_bar.currentIndex() # type: ignore[attr-defined] + self._tab_bar.update_tab(idx, method=method, name=name) # type: ignore[attr-defined] + self._breadcrumb_bar.set_path( # type: ignore[attr-defined] + [{"name": name, "type": "request", "id": 0}] + ) + if viewer is not None and http_response is not None: + viewer.load_stored_response(http_response) + self._refresh_variable_map( # type: ignore[attr-defined] + editor, + None, + ctx.local_overrides, + collection_id=ctx.variable_collection_id, + ) + self._refresh_sidebar(history_load_detail=False) # type: ignore[attr-defined] + QTimer.singleShot(0, self._persist_open_tabs) # type: ignore[attr-defined] + + def _tab_context_for_request_id(self, request_id: int) -> TabContext | None: + """Return the tab context for an open request tab, if any.""" + for ctx in self._tabs.values(): # type: ignore[attr-defined] + if ctx.tab_type == "request" and ctx.request_id == request_id: + return cast(TabContext, ctx) + return None diff --git a/src/ui/main_window/history_navigation/orphan_open.py b/src/ui/main_window/history_navigation/orphan_open.py new file mode 100644 index 0000000..1745457 --- /dev/null +++ b/src/ui/main_window/history_navigation/orphan_open.py @@ -0,0 +1,67 @@ +"""Background load of full send-history rows for orphan (deleted-request) tab open.""" + +from __future__ import annotations + +from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal + +from services.request_history_service import RequestHistoryService + + +class OrphanHistoryOpenSignals(QObject): + """Delivers a loaded history entry on the GUI thread (queued connection).""" + + finished = Signal(int, object) + + +class OrphanHistoryOpenRunnable(QRunnable): + """Load a full history entry (body files + snapshot) off the GUI thread.""" + + def __init__( + self, + signals: OrphanHistoryOpenSignals, + generation: int, + entry_id: int, + ) -> None: + """Store job parameters for :meth:`run`.""" + super().__init__() + self.setAutoDelete(True) + self._signals = signals + self._generation = generation + self._entry_id = entry_id + + def run(self) -> None: + """Load entry files and emit ``(generation, payload)`` or ``(generation, None)``.""" + entry = RequestHistoryService.get_entry(self._entry_id) + if entry is None: + self._signals.finished.emit(self._generation, None) + return + http = RequestHistoryService.entry_to_http_response_dict(entry) + self._signals.finished.emit(self._generation, {"entry": entry, "http": http}) + + +class OrphanHistoryOpenLoader(QObject): + """Schedule :class:`OrphanHistoryOpenRunnable` jobs (one result at a time).""" + + finished = Signal(int, object) + + def __init__(self, parent: QObject | None = None) -> None: + """Create a loader owned by *parent* (typically :class:`MainWindow`).""" + super().__init__(parent) + self._signals = OrphanHistoryOpenSignals(self) + self._signals.finished.connect(self._forward_finished) + self._active_generation: int | None = None + + def cancel(self) -> None: + """Ignore in-flight results (pool workers are not interrupted).""" + self._active_generation = None + + def load(self, entry_id: int, generation: int) -> None: + """Start loading *entry_id*; emit ``finished(generation, entry)`` when done.""" + self._active_generation = generation + runnable = OrphanHistoryOpenRunnable(self._signals, generation, entry_id) + QThreadPool.globalInstance().start(runnable) + + def _forward_finished(self, generation: int, payload: object) -> None: + if generation != self._active_generation: + return + self.finished.emit(generation, payload) diff --git a/src/ui/main_window/send_pipeline.py b/src/ui/main_window/send_pipeline.py index 8be41f3..9fcf228 100644 --- a/src/ui/main_window/send_pipeline.py +++ b/src/ui/main_window/send_pipeline.py @@ -106,7 +106,12 @@ def _current_tab_context(self) -> TabContext | None: ... if TYPE_CHECKING: - def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: ... + def _refresh_sidebar( + self, + ctx: TabContext | None = None, + *, + history_load_detail: bool = True, + ) -> None: ... def _on_send_request(self) -> None: """Send the current request on a background thread.""" @@ -162,10 +167,17 @@ def _on_send_request(self) -> None: from services.collection_service import CollectionService auth_data = editor._get_auth_data() - if ctx and ctx.request_id and auth_data is None: - inherited = CollectionService.get_request_inherited_auth(ctx.request_id) - if inherited: - auth_data = inherited + if ctx and auth_data is None: + if ctx.request_id is not None: + inherited = CollectionService.get_request_inherited_auth(ctx.request_id) + if inherited: + auth_data = inherited + elif ctx.variable_collection_id is not None: + inherited = CollectionService.get_collection_inherited_auth( + ctx.variable_collection_id + ) + if inherited: + auth_data = inherited request_id = ctx.request_id if ctx else None request_name = "" @@ -178,7 +190,16 @@ def _on_send_request(self) -> None: elif ctx and ctx.draft_name: request_name = str(ctx.draft_name) - self._pending_request_snapshot = editor.get_request_data() + snapshot = editor.get_request_data() + if ctx and ctx.request_id is not None: + req_model = CollectionService.get_request(ctx.request_id) + if req_model is not None: + snapshot = dict(snapshot) + snapshot["collection_id"] = req_model.collection_id + elif ctx and ctx.variable_collection_id is not None: + snapshot = dict(snapshot) + snapshot["collection_id"] = ctx.variable_collection_id + self._pending_request_snapshot = snapshot self._pending_history_context = { "request_id": request_id, "request_name": request_name, @@ -223,6 +244,7 @@ def _on_send_request(self) -> None: message_prefix="Pre-request script", ) + variable_collection_id = ctx.variable_collection_id if ctx else None self._launch_http_send( ctx, viewer=viewer, @@ -233,6 +255,7 @@ def _on_send_request(self) -> None: auth_data=auth_data, request_id=request_id, request_name=request_name, + variable_collection_id=variable_collection_id, pre_scripts=pre_scripts, test_scripts=test_scripts, declarative_test_script=declarative_test_script, @@ -276,6 +299,7 @@ def _launch_http_send( auth_data: dict | None, request_id: int | None, request_name: str, + variable_collection_id: int | None = None, pre_scripts: list[Any] | None = None, test_scripts: list[Any] | None = None, declarative_test_script: Any = None, @@ -305,6 +329,7 @@ def _launch_http_send( request_id=request_id, request_name=request_name, auth_data=auth_data, + variable_collection_id=variable_collection_id, local_overrides={k: v["value"] for k, v in ctx.local_overrides.items()} if ctx else None, diff --git a/src/ui/main_window/send_pipeline_postresponse.py b/src/ui/main_window/send_pipeline_postresponse.py index 3331097..c7d96cf 100644 --- a/src/ui/main_window/send_pipeline_postresponse.py +++ b/src/ui/main_window/send_pipeline_postresponse.py @@ -97,6 +97,9 @@ def _record_request_history( active_id = ctx.request_id if panel is not None and recorded_id is not None and active_id == recorded_id: panel.refresh() + global_panel = getattr(window, "_global_history_panel", None) + if global_panel is not None: + global_panel.refresh() except Exception: import logging diff --git a/src/ui/main_window/tab_controller.py b/src/ui/main_window/tab_controller.py index 780cead..c0d3acd 100644 --- a/src/ui/main_window/tab_controller.py +++ b/src/ui/main_window/tab_controller.py @@ -8,6 +8,8 @@ import contextlib import logging +import warnings +from collections.abc import Callable from typing import TYPE_CHECKING, Any, cast from PySide6.QtCore import QTimer @@ -38,6 +40,14 @@ _MAX_HISTORY = 50 +def _safe_signal_disconnect(signal: object, slot: Callable[..., object]) -> None: + """Disconnect *slot* from *signal* without Qt ``RuntimeWarning`` noise.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + with contextlib.suppress(TypeError, RuntimeError): + signal.disconnect(slot) # type: ignore[attr-defined] + + class _TabControllerMixin: """Mixin that manages request / folder tab lifecycle. @@ -91,8 +101,17 @@ def _refresh_variable_map( editor: RequestEditorWidget, request_id: int | None, local_overrides: dict | None = ..., + *, + collection_id: int | None = ..., ) -> None: ... - def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: ... + def _refresh_sidebar( + self, + ctx: TabContext | None = None, + *, + history_load_detail: bool = True, + ) -> None: ... + def _on_editor_request_changed(self, _data: dict[str, Any] | None = None) -> None: ... + def _on_viewer_save_availability_changed(self, _enabled: bool = False) -> None: ... def _schedule_sidebar_snippet_refresh(self) -> None: ... def _on_environments_data_changed(self) -> None: ... def _record_tab_activation(self, index: int) -> None: ... @@ -237,11 +256,11 @@ def _create_tab( editor.save_requested.connect(self._on_save_request) editor.dirty_changed.connect(self._sync_save_btn) editor.dirty_changed.connect(self._on_editor_dirty_changed) - editor.request_changed.connect(lambda _: self._schedule_sidebar_snippet_refresh()) + editor.request_changed.connect(self._on_editor_request_changed) editor.scripts_tab_active_changed.connect(self._on_editor_scripts_tab_changed) viewer.save_response_requested.connect(self._on_save_response) viewer.replay_history_link_clicked.connect(self._on_replay_history_link_clicked) - viewer.save_availability_changed.connect(lambda _enabled: self._refresh_sidebar()) + viewer.save_availability_changed.connect(self._on_viewer_save_availability_changed) # Now switch to the tab (triggers _on_tab_changed safely) self._tab_bar.setCurrentIndex(idx) @@ -580,7 +599,14 @@ def _on_tab_change_settled(self, *, sync_tree: bool = True) -> None: else: self._breadcrumb_bar.clear() # Refresh variable map for highlighting and tooltips - self._refresh_variable_map(ctx.require_editor(), ctx.request_id, ctx.local_overrides) + self._refresh_variable_map( + ctx.require_editor(), + ctx.request_id, + ctx.local_overrides, + collection_id=self._variable_map_collection_id( # type: ignore[attr-defined] + ctx.request_id, ctx.variable_collection_id + ), + ) # Refresh right sidebar for the active tab. self._refresh_sidebar(ctx) @@ -622,6 +648,10 @@ def _persist_open_tabs(self) -> None: """Save the current tab list to settings for session restore.""" if getattr(self, "_restoring_session", False): return + from shiboken6 import Shiboken + + if not Shiboken.isValid(self._tab_bar): + return tabs_list: list[dict[str, object]] = [] all_indices = sorted(set(self._tabs) | set(self._deferred_tabs)) for idx in all_indices: @@ -661,12 +691,14 @@ def _persist_open_tabs(self) -> None: ) elif ctx.tab_type == "request" and ctx.request_id is None: # Draft (unsaved) tab — snapshot the editor state. - ed = ctx.require_editor() + draft_ed = ctx.editor + if draft_ed is None or not Shiboken.isValid(draft_ed): + continue entry: dict[str, object] = { "type": "draft", - "data": ed.get_request_data(), + "data": draft_ed.get_request_data(), } - draft_debug = ed.collect_draft_debug_blob() + draft_debug = draft_ed.collect_draft_debug_blob() if draft_debug: entry["debug"] = draft_debug if ctx.draft_name: @@ -838,11 +870,11 @@ def _materialise_deferred_tab(self, index: int) -> None: editor.save_requested.connect(self._on_save_request) editor.dirty_changed.connect(self._sync_save_btn) editor.dirty_changed.connect(self._on_editor_dirty_changed) - editor.request_changed.connect(lambda _: self._schedule_sidebar_snippet_refresh()) + editor.request_changed.connect(self._on_editor_request_changed) editor.scripts_tab_active_changed.connect(self._on_editor_scripts_tab_changed) viewer.save_response_requested.connect(self._on_save_response) viewer.replay_history_link_clicked.connect(self._on_replay_history_link_clicked) - viewer.save_availability_changed.connect(lambda _enabled: self._refresh_sidebar()) + viewer.save_availability_changed.connect(self._on_viewer_save_availability_changed) # Fetch breadcrumb once — reused by both the tab tooltip and # _on_tab_changed (via _cached_crumbs) to avoid a duplicate query. @@ -1024,43 +1056,28 @@ def _on_tab_close(self, index: int) -> None: del ctx self._tab_bar.remove_request_tab(index) else: - # Request tab cleanup - # Grab local references before dispose() nulls the context. + # Request tab — detach UI first; heavy teardown runs on the next tick. editor = ctx.require_editor() viewer = ctx.require_response_viewer() - - flush_debug = getattr(editor, "flush_debug_metadata_persist_sync", None) - if callable(flush_debug): - flush_debug() - - # Disconnect signals that reference MainWindow slots so the - # sender objects can be garbage-collected. - editor.send_requested.disconnect(self._on_send_request) - editor.save_requested.disconnect(self._on_save_request) - editor.dirty_changed.disconnect(self._sync_save_btn) - editor.dirty_changed.disconnect(self._on_editor_dirty_changed) - editor.request_changed.disconnect() - viewer.save_response_requested.disconnect(self._on_save_response) - viewer.replay_history_link_clicked.disconnect(self._on_replay_history_link_clicked) - - # Remove from stacked widgets and detach from parent hierarchy. self._editor_stack.removeWidget(editor) self._response_stack.removeWidget(viewer) - - # Clear heavy data so memory is freed even before the C++ - # destructor runs. - viewer.clear() - - # Detach from any Qt parent so the C++ side is destroyed when - # the Python wrapper is garbage-collected. editor.setParent(None) viewer.setParent(None) - - # Release all Python references held by the TabContext. - ctx.dispose() - del editor, viewer, ctx - self._tab_bar.remove_request_tab(index) + self._reindex_tabs_after_close(index) + target_new_index = self._normalize_target_index_after_close(index, target_old_index) + if target_new_index is not None and 0 <= target_new_index < self._tab_bar.count(): + self._tab_bar.setCurrentIndex(target_new_index) + self._on_tab_changed(target_new_index) + else: + self._on_tab_changed(self._tab_bar.currentIndex()) + self._flush_tab_change() + QTimer.singleShot( + 0, + lambda e=editor, v=viewer, c=ctx: self._dispose_request_tab_widgets(e, v, c), + ) + QTimer.singleShot(0, self._persist_open_tabs) + return # Re-index remaining tabs (both materialised and deferred) self._reindex_tabs_after_close(index) @@ -1074,6 +1091,43 @@ def _on_tab_close(self, index: int) -> None: self._flush_tab_change() self._persist_open_tabs() + def _dispose_request_tab_widgets( + self, + editor: RequestEditorWidget, + viewer: ResponseViewerWidget, + ctx: TabContext, + ) -> None: + """Disconnect and clear request-tab widgets after the tab UI has closed.""" + flush_debug = getattr(editor, "flush_debug_metadata_persist_sync", None) + if callable(flush_debug): + flush_debug() + _safe_signal_disconnect(editor.send_requested, self._on_send_request) + _safe_signal_disconnect(editor.debug_step_requested, self._on_debug_step) + _safe_signal_disconnect(editor.open_collection_requested, self._open_folder) + _safe_signal_disconnect( + editor.open_scripting_settings_requested, + self._on_open_scripting_settings, + ) + _safe_signal_disconnect(editor.save_requested, self._on_save_request) + _safe_signal_disconnect(editor.dirty_changed, self._sync_save_btn) + _safe_signal_disconnect(editor.dirty_changed, self._on_editor_dirty_changed) + _safe_signal_disconnect(editor.request_changed, self._on_editor_request_changed) + _safe_signal_disconnect( + editor.scripts_tab_active_changed, + self._on_editor_scripts_tab_changed, + ) + _safe_signal_disconnect(viewer.save_response_requested, self._on_save_response) + _safe_signal_disconnect( + viewer.replay_history_link_clicked, + self._on_replay_history_link_clicked, + ) + _safe_signal_disconnect( + viewer.save_availability_changed, + self._on_viewer_save_availability_changed, + ) + viewer.clear() + ctx.dispose() + def _reindex_tabs_after_close(self, closed_index: int) -> None: """Shift tab indices down after removing a tab at *closed_index*.""" self._tabs = { diff --git a/src/ui/main_window/variable_controller.py b/src/ui/main_window/variable_controller.py index 896f2e9..3c3b5b0 100644 --- a/src/ui/main_window/variable_controller.py +++ b/src/ui/main_window/variable_controller.py @@ -8,7 +8,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from PySide6.QtCore import QTimer @@ -47,6 +47,8 @@ def _refresh_variable_map( editor: RequestEditorWidget, request_id: int | None, local_overrides: dict[str, LocalOverride] | None = None, + *, + collection_id: int | None = None, ) -> None: """Build the combined variable map and push it to *editor*. @@ -54,9 +56,18 @@ def _refresh_variable_map( via the variable popup), those entries are layered on top with ``is_local=True`` so the popup shows a **Local** badge while preserving the original source for the **Update** action. + + For draft tabs opened from deleted-request history, pass + *collection_id* (from ``TabContext.variable_collection_id``) so + collection variables resolve in the editor the same way as in the + variables sidebar. """ env_id = self._env_selector.current_environment_id() - variables = EnvironmentService.build_combined_variable_detail_map(env_id, request_id) + variables = EnvironmentService.build_combined_variable_detail_map( + env_id, + request_id, + collection_id=collection_id if request_id is None else None, + ) # Layer per-request overrides on top if local_overrides: @@ -70,6 +81,16 @@ def _refresh_variable_map( editor.set_variable_map(variables) + @staticmethod + def _variable_map_collection_id( + request_id: int | None, + variable_collection_id: int | None, + ) -> int | None: + """Return the collection id used for variable resolution on draft tabs.""" + if request_id is not None: + return None + return variable_collection_id + def _on_environment_changed(self, _env_id: object) -> None: """Refresh variable maps in all open request editors.""" from ui.widgets.variable_popup import VariablePopup @@ -77,7 +98,14 @@ def _on_environment_changed(self, _env_id: object) -> None: VariablePopup.set_has_environment(self._env_selector.current_environment_id() is not None) for ctx in self._tabs.values(): if ctx.tab_type == "request" and ctx.editor is not None: - self._refresh_variable_map(ctx.editor, ctx.request_id, ctx.local_overrides) + self._refresh_variable_map( + ctx.editor, + ctx.request_id, + ctx.local_overrides, + collection_id=self._variable_map_collection_id( + ctx.request_id, ctx.variable_collection_id + ), + ) self._refresh_sidebar() def _on_environments_data_changed(self) -> None: @@ -86,7 +114,12 @@ def _on_environments_data_changed(self) -> None: for tab_ctx in self._tabs.values(): if tab_ctx.tab_type == "request" and tab_ctx.editor is not None: self._refresh_variable_map( - tab_ctx.editor, tab_ctx.request_id, tab_ctx.local_overrides + tab_ctx.editor, + tab_ctx.request_id, + tab_ctx.local_overrides, + collection_id=self._variable_map_collection_id( + tab_ctx.request_id, tab_ctx.variable_collection_id + ), ) self._refresh_sidebar() @@ -113,7 +146,14 @@ def _on_variable_updated( # Refresh variable maps in all open request editors for ctx in self._tabs.values(): if ctx.tab_type == "request" and ctx.editor is not None: - self._refresh_variable_map(ctx.editor, ctx.request_id, ctx.local_overrides) + self._refresh_variable_map( + ctx.editor, + ctx.request_id, + ctx.local_overrides, + collection_id=self._variable_map_collection_id( + ctx.request_id, ctx.variable_collection_id + ), + ) def _on_local_variable_override( self, @@ -137,7 +177,14 @@ def _on_local_variable_override( "original_source": source, "original_source_id": source_id, } - self._refresh_variable_map(ctx.editor, ctx.request_id, ctx.local_overrides) + self._refresh_variable_map( + ctx.editor, + ctx.request_id, + ctx.local_overrides, + collection_id=self._variable_map_collection_id( + ctx.request_id, ctx.variable_collection_id + ), + ) def _on_reset_local_override(self, var_name: str) -> None: """Remove a per-request variable override and refresh. @@ -150,7 +197,14 @@ def _on_reset_local_override(self, var_name: str) -> None: return ctx.local_overrides.pop(var_name, None) - self._refresh_variable_map(ctx.editor, ctx.request_id, ctx.local_overrides) + self._refresh_variable_map( + ctx.editor, + ctx.request_id, + ctx.local_overrides, + collection_id=self._variable_map_collection_id( + ctx.request_id, ctx.variable_collection_id + ), + ) def _on_add_unresolved_variable( self, @@ -168,15 +222,21 @@ def _on_add_unresolved_variable( ctx = self._current_tab_context() if target == "collection": - request_id = ctx.request_id if ctx else None - if request_id is None: + coll_id: int | None = None + if ctx is not None: + if ctx.request_id is not None: + from database.models.collections.collection_query_repository import ( + get_request_by_id, + ) + + req = get_request_by_id(ctx.request_id) + if req is not None: + coll_id = req.collection_id + elif ctx.variable_collection_id is not None: + coll_id = ctx.variable_collection_id + if coll_id is None: return - from database.models.collections.collection_query_repository import get_request_by_id - - req = get_request_by_id(request_id) - if req is None: - return - EnvironmentService.add_variable("collection", req.collection_id, var_name, value) + EnvironmentService.add_variable("collection", coll_id, var_name, value) elif target == "environment": env_id = self._env_selector.current_environment_id() if env_id is None: @@ -192,13 +252,21 @@ def _on_add_unresolved_variable( tab_ctx.editor, tab_ctx.request_id, tab_ctx.local_overrides, + collection_id=self._variable_map_collection_id( + tab_ctx.request_id, tab_ctx.variable_collection_id + ), ) self._refresh_sidebar() # ------------------------------------------------------------------ # Right-sidebar helpers # ------------------------------------------------------------------ - def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: + def _refresh_sidebar( + self, + ctx: TabContext | None = None, + *, + history_load_detail: bool = True, + ) -> None: """Update the right sidebar panels for the active tab.""" if ctx is None: ctx = self._current_tab_context() @@ -235,7 +303,9 @@ def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: ctx.tab_type == "request" and ctx.editor is not None and ctx.response_viewer is not None ): variables = EnvironmentService.build_combined_variable_detail_map( - env_id, ctx.request_id + env_id, + ctx.request_id, + collection_id=ctx.variable_collection_id if ctx.request_id is None else None, ) # Layer per-request overrides on top if ctx.local_overrides: @@ -253,10 +323,15 @@ def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: sub = EnvironmentService.substitute # Resolve inherited auth for sidebar / snippet display auth = data.get("auth") - if auth is None and ctx.request_id: + if auth is None: from services.collection_service import CollectionService - auth = CollectionService.get_request_inherited_auth(ctx.request_id) + if ctx.request_id is not None: + auth = CollectionService.get_request_inherited_auth(ctx.request_id) + elif ctx.variable_collection_id is not None: + auth = CollectionService.get_collection_inherited_auth( + ctx.variable_collection_id + ) auth = self._substitute_auth(auth, flat_vars) self._right_sidebar.show_request_panels( variables, @@ -271,6 +346,9 @@ def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: request_name = None saved_responses = [] is_persisted_request = ctx.request_id is not None + from_deleted_request_history = ( + not is_persisted_request and ctx.variable_collection_id is not None + ) if ctx.request_id is not None: from services.collection_service import CollectionService @@ -282,13 +360,24 @@ def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: items=saved_responses, can_save_current=ctx.response_viewer.has_live_response(), is_persisted_request=is_persisted_request, + from_deleted_request_history=from_deleted_request_history, ) self._right_sidebar.set_request_history_context( request_id=ctx.request_id, request_name=request_name, is_persisted_request=is_persisted_request, + load_detail=history_load_detail, + from_deleted_request_history=from_deleted_request_history, ) + def _on_editor_request_changed(self, _data: dict[str, Any] | None = None) -> None: + """Slot for ``RequestEditorWidget.request_changed`` (debounced).""" + self._schedule_sidebar_snippet_refresh() + + def _on_viewer_save_availability_changed(self, _enabled: bool = False) -> None: + """Slot for ``ResponseViewerWidget.save_availability_changed``.""" + self._refresh_sidebar() + def _schedule_sidebar_snippet_refresh(self) -> None: """Debounce snippet refresh (300 ms) on request editor changes.""" self._sidebar_debounce.start(300) diff --git a/src/ui/main_window/window.py b/src/ui/main_window/window.py index d618a5e..e2425a1 100644 --- a/src/ui/main_window/window.py +++ b/src/ui/main_window/window.py @@ -31,6 +31,7 @@ from ui.environments.environment_sidebar_panel import EnvironmentSidebarPanel from ui.loading_screen import LoadingScreen from ui.main_window.draft_controller import _DraftControllerMixin +from ui.main_window.history_navigation import _HistoryNavigationMixin from ui.main_window.send_pipeline import _SendPipelineMixin from ui.main_window.tab_controller import _TabControllerMixin from ui.main_window.tab_nav import _TabNavHistoryMixin @@ -54,6 +55,7 @@ class MainWindow( _SendPipelineMixin, + _HistoryNavigationMixin, _VariableControllerMixin, _DraftControllerMixin, _TabNavHistoryMixin, @@ -87,6 +89,8 @@ def __init__( self._pending_request_snapshot = None self._pending_history_context = None self._suppress_history_record = False + self._global_history_open_busy = False + self._init_orphan_history_open_loader() self.setWindowTitle("Postmark") # Pre-size to the available screen geometry so the window fills @@ -130,10 +134,14 @@ def __init__( from ui.sidebar.history.panel import HistoryPanel self._request_history_panel = HistoryPanel() + self._global_history_panel: HistoryPanel = HistoryPanel() + self._global_history_panel.set_global_mode() self._right_sidebar = RightSidebar(request_history_panel=self._request_history_panel) self._request_history_panel.refresh_requested.connect(self._request_history_panel.refresh) self._request_history_panel.replay_requested.connect(self._replay_request_history_entry) self._request_history_panel.delete_requested.connect(self._delete_request_history_entry) + self._global_history_panel.refresh_requested.connect(self._global_history_panel.refresh) + self._global_history_panel.entry_open_requested.connect(self._open_from_global_history) if self._theme_manager is not None: self._theme_manager.theme_changed.connect(self._left_sidebar.refresh_theme) self._theme_manager.theme_changed.connect(self._right_sidebar.refresh_theme) @@ -574,6 +582,7 @@ def _setup_ui(self) -> None: self._local_scripts_snippets_splitter.setSizes([360, 200]) self._left_sidebar.set_local_scripts_panel(self._local_scripts_snippets_splitter) + self._left_sidebar.set_history_panel(self._global_history_panel) self._left_sidebar.install_in_splitter(self._main_splitter) # --- Centre: vertical splitter (request + response) --- diff --git a/src/ui/request/http_worker.py b/src/ui/request/http_worker.py index a8f714d..0fea540 100644 --- a/src/ui/request/http_worker.py +++ b/src/ui/request/http_worker.py @@ -61,6 +61,7 @@ def __init__(self) -> None: self._timeout: float = 30.0 self._env_id: int | None = None self._request_id: int | None = None + self._variable_collection_id: int | None = None self._request_name: str = "" self._auth_data: dict | None = None self._local_overrides: dict[str, str] = {} @@ -84,6 +85,7 @@ def set_request( request_id: int | None = None, request_name: str = "", auth_data: dict | None = None, + variable_collection_id: int | None = None, local_overrides: dict[str, str] | None = None, pre_scripts: list[ScriptEntry] | None = None, test_scripts: list[ScriptEntry] | None = None, @@ -113,6 +115,7 @@ def set_request( self._timeout = timeout self._env_id = env_id self._request_id = request_id + self._variable_collection_id = variable_collection_id self._request_name = request_name or "" self._auth_data = auth_data self._local_overrides = local_overrides or {} @@ -171,6 +174,7 @@ def run(self) -> None: variables = EnvironmentService.build_combined_variable_map( self._env_id, self._request_id, + collection_id=self._variable_collection_id, ) # 2b. Apply per-request local overrides (highest precedence) diff --git a/src/ui/request/navigation/tab_manager.py b/src/ui/request/navigation/tab_manager.py index b4965ab..fc533e1 100644 --- a/src/ui/request/navigation/tab_manager.py +++ b/src/ui/request/navigation/tab_manager.py @@ -127,6 +127,7 @@ def __init__( self.last_activated_order: int = 0 self.nav_token: int = nav_token if nav_token is not None else allocate_tab_nav_token() self.replay_source_entry_id: int | None = None + self.variable_collection_id: int | None = None def require_editor(self) -> RequestEditorWidget: """Return the request editor when this tab mounts one. diff --git a/src/ui/request/response_viewer/viewer_widget.py b/src/ui/request/response_viewer/viewer_widget.py index 64faebc..c572114 100644 --- a/src/ui/request/response_viewer/viewer_widget.py +++ b/src/ui/request/response_viewer/viewer_widget.py @@ -48,6 +48,7 @@ COLOR_WHITE, ) from ui.widgets.code_editor import CodeEditorWidget +from ui.widgets.text_format_async import AsyncTextFormatRunner, skip_inline_text_for_async_pretty from ui.widgets.info_popup import ClickableLabel if TYPE_CHECKING: @@ -172,6 +173,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self._last_status_color: str = "" self._last_elapsed_ms: float = 0.0 self._last_live_response: dict | None = None + self._body_format_generation = 0 + self._body_format_runner = AsyncTextFormatRunner(self) + self._body_format_runner.formatted.connect(self._on_async_body_formatted) # -- Progress bar (loading state) ----------------------------- self._progress_bar = QProgressBar() @@ -384,9 +388,30 @@ def clear_replay_history_source(self) -> None: def show_loading(self) -> None: """Display the indeterminate progress bar (request in flight).""" - self._replay_indicator.hide() + if not self.has_live_response(): + self._discard_stored_response_for_send() + else: + self._replay_indicator.hide() self._set_state("loading") + def _discard_stored_response_for_send(self) -> None: + """Clear stored-history response data before a new in-flight send.""" + self._replay_indicator.hide() + self._clear_stored_script_tabs() + self._last_live_response = None + self._set_save_enabled(False) + self._status_label.setText("") + self._time_label.setText("") + self._size_label.setText("") + self._set_body_simple_error_mode(False) + self._body_error_edit.clear() + self._body_edit.clear() + self._headers_edit.clear() + self._cookies_edit.clear() + self._request_headers_edit.clear() + self._raw_body = "" + self._filtered_body = "" + def show_error(self, message: str) -> None: """Display an error in the tabbed view (e.g. validation, worker exception).""" self.load_response({"error": str(message)}) @@ -407,6 +432,27 @@ def load_response(self, data: dict) -> None: self._render_response_data(data) + def load_stored_response(self, data: dict) -> None: + """Show a persisted send-history response without enabling Save Response. + + Clears test/pre-request script tabs so stale output from a prior live + send is not shown alongside historical data. + """ + self._replay_indicator.hide() + self._clear_stored_script_tabs() + self._last_live_response = None + self._set_save_enabled(False) + if "error" in data: + self._load_network_error_response(data) + return + self._render_response_data(data) + + def _clear_stored_script_tabs(self) -> None: + """Hide script result tabs before showing stored or error responses.""" + self._clear_test_results_rows() + self._tabs.setTabVisible(self._test_tab_index, False) + self._clear_pre_request_tab() + def _load_network_error_response(self, data: dict) -> None: """Render a failed send (``error`` in :class:`HttpResponseDict`) in the tabbed view.""" self._last_live_response = None @@ -532,6 +578,7 @@ def _render_response_data(self, data: dict) -> None: self._network_data = data.get("network") # Body — store raw and apply current format + self._body_format_runner.cancel() self._raw_body = data.get("body", "") self._apply_body_format() @@ -567,6 +614,8 @@ def _populate_request_headers(self, data: dict) -> None: def clear(self) -> None: """Reset to the empty state.""" + self._body_format_runner.cancel() + self._body_format_generation += 1 self._set_save_enabled(False) self._set_state("empty") self._status_label.setText("") @@ -604,17 +653,20 @@ def _on_format_changed(self, _text: str) -> None: def _apply_body_format(self) -> None: """Render ``_raw_body`` according to the current format selection. - When a filter is active the filter expression is re-evaluated - against the (possibly reformatted) body so the filtered view - stays consistent across format switches. + Pretty/JSON modes show raw text immediately, then replace with + formatted output from a background thread when ready. """ if self._body_simple_error_mode: return + self._body_format_runner.cancel() + self._body_format_generation += 1 + generation = self._body_format_generation + fmt = self._format_combo.currentText() body = self._raw_body + use_async_pretty = fmt in ("Pretty", "JSON") and bool(body) if fmt == "Pretty" or fmt == "JSON": - body = self._try_pretty_json(body) self._body_edit.set_language("json") elif fmt == "XML": self._body_edit.set_language("xml") @@ -623,16 +675,38 @@ def _apply_body_format(self) -> None: else: self._body_edit.set_language("text") - # Re-apply active filter if one exists if self._is_filtered and self._filter_expression: self._run_filter(self._filter_expression, body) + if use_async_pretty: + self._body_format_runner.format_async( + generation, + body, + "json", + pretty=True, + ) return - self._body_edit.set_text(body) + if not skip_inline_text_for_async_pretty(body, pretty=use_async_pretty): + self._body_edit.set_text(body) + if use_async_pretty: + self._body_format_runner.format_async( + generation, + body, + "json", + pretty=True, + ) - # Update filter placeholder based on detected language self._update_filter_placeholder() + def _on_async_body_formatted(self, generation: int, text: str) -> None: + """Apply background pretty-print result when still the active body job.""" + if generation != self._body_format_generation or self._body_simple_error_mode: + return + if self._is_filtered and self._filter_expression: + self._run_filter(self._filter_expression, text) + return + self._body_edit.set_text(text) + @staticmethod def _try_pretty_json(text: str) -> str: """Attempt to pretty-print JSON; return original text on failure.""" @@ -647,20 +721,22 @@ def _try_pretty_json(text: str) -> str: # -- Beautify / Save ----------------------------------------------- def _on_beautify(self) -> None: - """Format the response body using pretty-printing.""" + """Format the response body using pretty-printing (background thread).""" if self._body_simple_error_mode: return body = self._raw_body if not body: return - pretty = self._try_pretty_json(body) - if pretty != body: - self._body_edit.set_text(pretty) - return - # Try XML beautification - pretty = self._try_pretty_xml(body) - if pretty != body: - self._body_edit.set_text(pretty) + self._body_format_runner.cancel() + self._body_format_generation += 1 + generation = self._body_format_generation + language = self._body_edit.language or "text" + self._body_format_runner.format_async( + generation, + body, + language, + pretty=True, + ) @staticmethod def _try_pretty_xml(text: str) -> str: diff --git a/src/ui/sidebar/history/date_filter/__init__.py b/src/ui/sidebar/history/date_filter/__init__.py new file mode 100644 index 0000000..c21612f --- /dev/null +++ b/src/ui/sidebar/history/date_filter/__init__.py @@ -0,0 +1,5 @@ +"""Date-range filter popup for send history.""" + +from ui.sidebar.history.date_filter.popup import HistoryDateRangeFilterPopup + +__all__ = ["HistoryDateRangeFilterPopup"] diff --git a/src/ui/sidebar/history/date_filter/mixin.py b/src/ui/sidebar/history/date_filter/mixin.py new file mode 100644 index 0000000..3da0502 --- /dev/null +++ b/src/ui/sidebar/history/date_filter/mixin.py @@ -0,0 +1,131 @@ +"""Date-range filter state and controls for :class:`HistoryPanel`.""" + +# Mixin methods use attributes defined on :class:`HistoryPanel`. +# mypy: disable-error-code=attr-defined + +from __future__ import annotations + +from datetime import date +from typing import cast + +from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget + +from ui.sidebar.history.date_filter import HistoryDateRangeFilterPopup + + +class _HistoryDateFilterMixin: + """Shared date filter button, popup, and refresh kwargs.""" + + _date_filter_from: date | None + _date_filter_to: date | None + _date_filter_btn: QPushButton + _date_filter_popup: HistoryDateRangeFilterPopup | None + _search_row: QWidget + + def _init_date_filter_state(self) -> None: + """Create the filter button and search row container.""" + self._date_filter_from = None + self._date_filter_to = None + self._date_filter_popup = None + + self._date_filter_btn = self._make_icon_btn( + "funnel", + "Filter by date", + "iconButton", + None, + ) + self._date_filter_btn.setCheckable(True) + self._date_filter_btn.clicked.connect(self._on_date_filter_btn_clicked) + + self._search_row = QWidget() + search_layout = QHBoxLayout(self._search_row) + search_layout.setContentsMargins(0, 0, 0, 0) + search_layout.setSpacing(4) + search_layout.addWidget(self._history_search_input, 1) + search_layout.addWidget(self._date_filter_btn) + + def _on_date_filter_btn_clicked(self) -> None: + # QPushButton toggles ``checked`` before the click handler; restore active-only state. + self._sync_date_filter_chrome() + self._toggle_date_range_filter(self._date_filter_btn) + + def _toggle_date_range_filter(self, anchor: QWidget) -> None: + """Show or hide the date-range popup below *anchor*.""" + if self._date_filter_popup is not None and self._date_filter_popup.isVisible(): + self._date_filter_popup.hide() + return + if self._date_filter_popup is None: + host = cast(QWidget, self) + self._date_filter_popup = HistoryDateRangeFilterPopup(host) + self._date_filter_popup.filter_applied.connect(self._on_date_range_applied) + self._date_filter_popup.set_range(self._date_filter_from, self._date_filter_to) + self._date_filter_popup.show_below(anchor) + + def _on_date_range_applied( + self, + executed_from: date | None, + executed_to: date | None, + ) -> None: + """Store the range and reload the list.""" + self._date_filter_from = executed_from + self._date_filter_to = executed_to + self._sync_date_filter_chrome() + self.refresh() + + def _date_filter_active(self) -> bool: + return self._date_filter_from is not None or self._date_filter_to is not None + + def _date_filter_kwargs(self) -> dict[str, date | None]: + if not self._date_filter_active(): + return {} + return { + "executed_from": self._date_filter_from, + "executed_to": self._date_filter_to, + } + + def _sync_date_filter_chrome(self) -> None: + active = self._date_filter_active() + self._date_filter_btn.setChecked(active) + if active: + summary = self._date_filter_summary() + tip = f"Date filter: {summary} (click to change)" + else: + tip = "Filter by date" + self._date_filter_btn.setToolTip(tip) + + def _date_filter_summary(self) -> str: + if self._date_filter_from is None and self._date_filter_to is None: + return "" + if self._date_filter_from is None: + return f"through {self._date_filter_to:%d %b %Y}" + if self._date_filter_to is None: + return f"from {self._date_filter_from:%d %b %Y}" + if self._date_filter_from == self._date_filter_to: + return self._date_filter_from.strftime("%d %b %Y") + return f"{self._date_filter_from:%d %b %Y} - {self._date_filter_to:%d %b %Y}" + + def _place_date_filter_btn_for_mode(self) -> None: + """Global mode: header before refresh; request mode: search row.""" + parent = self._date_filter_btn.parentWidget() + if parent is not None: + lay = parent.layout() + if lay is not None: + lay.removeWidget(self._date_filter_btn) + + if self._is_global_mode() and self._global_header is not None: + header_lay = self._global_header.layout() + if isinstance(header_lay, QHBoxLayout): + header_lay.insertWidget(header_lay.count() - 1, self._date_filter_btn) + return + + search_lay = self._search_row.layout() + if isinstance(search_lay, QHBoxLayout): + search_lay.addWidget(self._date_filter_btn) + + def _reset_date_filter(self) -> None: + """Clear the active date range (e.g. on panel clear).""" + self._date_filter_from = None + self._date_filter_to = None + if self._date_filter_popup is not None: + self._date_filter_popup.hide() + self._sync_date_filter_chrome() diff --git a/src/ui/sidebar/history/date_filter/popup.py b/src/ui/sidebar/history/date_filter/popup.py new file mode 100644 index 0000000..f598aca --- /dev/null +++ b/src/ui/sidebar/history/date_filter/popup.py @@ -0,0 +1,146 @@ +"""Inline date-range filter popup for send history lists.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import date, timedelta + +from PySide6.QtCore import QDate, Qt, Signal +from PySide6.QtWidgets import ( + QDateEdit, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ui.widgets.info_popup import InfoPopup + + +class HistoryDateRangeFilterPopup(InfoPopup): + """Floating picker with presets, from/to dates, Apply, and Clear.""" + + filter_applied = Signal(object, object) # date | None, date | None + + def __init__(self, parent: QWidget | None = None) -> None: + """Build preset chips, date editors, and action row.""" + super().__init__(parent) + layout = self.content_layout + + title = QLabel("Filter by date") + title.setObjectName("infoPopupTitle") + layout.addWidget(title) + + presets = QHBoxLayout() + presets.setSpacing(4) + for label, handler in ( + ("Today", self._preset_today), + ("7 days", self._preset_last_days(7)), + ("30 days", self._preset_last_days(30)), + ("All", self._preset_all), + ): + btn = QPushButton(label) + btn.setObjectName("flatMutedButton") + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.clicked.connect(handler) + presets.addWidget(btn) + layout.addLayout(presets) + + range_row = QHBoxLayout() + range_row.setSpacing(8) + self._from_edit = self._make_date_edit() + self._to_edit = self._make_date_edit() + range_row.addWidget(self._labeled("From", self._from_edit), 1) + range_row.addWidget(self._labeled("To", self._to_edit), 1) + layout.addLayout(range_row) + + actions = QHBoxLayout() + actions.setSpacing(6) + self._clear_btn = QPushButton("Clear") + self._clear_btn.setObjectName("flatMutedButton") + self._clear_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._clear_btn.clicked.connect(self._on_clear) + actions.addWidget(self._clear_btn) + actions.addStretch(1) + self._apply_btn = QPushButton("Apply") + self._apply_btn.setObjectName("primaryButton") + self._apply_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._apply_btn.setDefault(True) + self._apply_btn.clicked.connect(self._on_apply) + actions.addWidget(self._apply_btn) + layout.addLayout(actions) + + self.setMinimumWidth(280) + + def set_range(self, executed_from: date | None, executed_to: date | None) -> None: + """Populate editors from the panel's active filter (or today when unset).""" + today = date.today() + from_d = executed_from or today + to_d = executed_to or today + if executed_from is None and executed_to is None: + self._from_edit.setDate(QDate.currentDate()) + self._to_edit.setDate(QDate.currentDate()) + return + self._from_edit.setDate(QDate(from_d.year, from_d.month, from_d.day)) + self._to_edit.setDate(QDate(to_d.year, to_d.month, to_d.day)) + + def _make_date_edit(self) -> QDateEdit: + edit = QDateEdit() + edit.setCalendarPopup(True) + edit.setDisplayFormat("dd MMM yyyy") + edit.setDate(QDate.currentDate()) + edit.setObjectName("historyDateFilterEdit") + return edit + + @staticmethod + def _labeled(text: str, widget: QWidget) -> QWidget: + host = QWidget() + col = QVBoxLayout(host) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(2) + label = QLabel(text) + label.setObjectName("mutedLabel") + col.addWidget(label) + col.addWidget(widget) + return host + + def _qdate_to_date(self, qd: QDate) -> date: + return date(qd.year(), qd.month(), qd.day()) + + def _read_range(self) -> tuple[date, date]: + from_d = self._qdate_to_date(self._from_edit.date()) + to_d = self._qdate_to_date(self._to_edit.date()) + if from_d > to_d: + from_d, to_d = to_d, from_d + return from_d, to_d + + def _emit_range(self, executed_from: date | None, executed_to: date | None) -> None: + self.filter_applied.emit(executed_from, executed_to) + self.hide() + + def _on_apply(self) -> None: + from_d, to_d = self._read_range() + self._emit_range(from_d, to_d) + + def _on_clear(self) -> None: + self._emit_range(None, None) + + def _preset_today(self) -> None: + today = QDate.currentDate() + self._from_edit.setDate(today) + self._to_edit.setDate(today) + self._on_apply() + + def _preset_last_days(self, days: int) -> Callable[[], None]: + def _apply() -> None: + today = date.today() + start = today - timedelta(days=max(0, days - 1)) + self._from_edit.setDate(QDate(start.year, start.month, start.day)) + self._to_edit.setDate(QDate(today.year, today.month, today.day)) + self._on_apply() + + return _apply + + def _preset_all(self) -> None: + self._on_clear() diff --git a/src/ui/sidebar/history/delegate.py b/src/ui/sidebar/history/delegate.py index db9f829..9f65230 100644 --- a/src/ui/sidebar/history/delegate.py +++ b/src/ui/sidebar/history/delegate.py @@ -18,6 +18,7 @@ ROLE_HISTORY_NAME = Qt.ItemDataRole.UserRole + 2 ROLE_HISTORY_META = Qt.ItemDataRole.UserRole + 3 ROLE_HISTORY_IS_DATE_GROUP = Qt.ItemDataRole.UserRole + 4 +ROLE_HISTORY_URL = Qt.ItemDataRole.UserRole + 5 _BADGE_WIDTH = 36 _BADGE_HEIGHT = 16 @@ -25,7 +26,10 @@ _LEFT_PADDING = 6 _TOP_PADDING = 6 _LINE_SPACING = 2 -_ROW_HEIGHT = 44 +_URL_LINE_HEIGHT = 14 +_META_LINE_HEIGHT = 16 +_ROW_HEIGHT_NO_URL = 44 +_ROW_HEIGHT_WITH_URL = 60 _DATE_GROUP_HEIGHT = 28 @@ -58,6 +62,7 @@ def paint( code = index.data(ROLE_HISTORY_CODE) name = index.data(ROLE_HISTORY_NAME) or "" + url = str(index.data(ROLE_HISTORY_URL) or "").strip() meta = index.data(ROLE_HISTORY_META) or "" rect: QRect = option.rect # type: ignore[assignment] @@ -101,14 +106,23 @@ def paint( elided = fm.elidedText(name, Qt.TextElideMode.ElideRight, available_w) painter.drawText(name_rect, Qt.AlignmentFlag.AlignVCenter, elided) - 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, - ) - + content_left = rect.left() + _LEFT_PADDING + content_width = rect.width() - _LEFT_PADDING * 2 + next_y = rect.top() + _TOP_PADDING + _BADGE_HEIGHT + _LINE_SPACING + + if url: + url_rect = QRect(content_left, next_y, content_width, _URL_LINE_HEIGHT) + url_font = QFont(painter.font()) + url_font.setPixelSize(11) + url_font.setBold(False) + painter.setPen(QPen(QColor(COLOR_TEXT_MUTED))) + painter.setFont(url_font) + fm_url = QFontMetrics(url_font) + elided_url = fm_url.elidedText(url, Qt.TextElideMode.ElideRight, content_width) + painter.drawText(url_rect, Qt.AlignmentFlag.AlignVCenter, elided_url) + next_y += _URL_LINE_HEIGHT + _LINE_SPACING + + meta_rect = QRect(content_left, next_y, content_width, _META_LINE_HEIGHT) meta_font = QFont(painter.font()) meta_font.setPixelSize(11) meta_font.setBold(False) @@ -156,4 +170,6 @@ def sizeHint( """Return a fixed row height for date groups and send rows.""" if index.data(ROLE_HISTORY_IS_DATE_GROUP): return QSize(option.rect.width(), _DATE_GROUP_HEIGHT) - return QSize(option.rect.width(), _ROW_HEIGHT) + url = str(index.data(ROLE_HISTORY_URL) or "").strip() + height = _ROW_HEIGHT_WITH_URL if url else _ROW_HEIGHT_NO_URL + return QSize(option.rect.width(), height) diff --git a/src/ui/sidebar/history/detail/__init__.py b/src/ui/sidebar/history/detail/__init__.py new file mode 100644 index 0000000..2643c10 --- /dev/null +++ b/src/ui/sidebar/history/detail/__init__.py @@ -0,0 +1,5 @@ +"""Async detail loading for :class:`HistoryPanel`.""" + +from ui.sidebar.history.detail.loader import HistoryDetailLoader + +__all__ = ["HistoryDetailLoader"] diff --git a/src/ui/sidebar/history/detail/loader.py b/src/ui/sidebar/history/detail/loader.py new file mode 100644 index 0000000..6901ef4 --- /dev/null +++ b/src/ui/sidebar/history/detail/loader.py @@ -0,0 +1,67 @@ +"""Background load of full send-history payloads for the detail pane.""" + +from __future__ import annotations + +from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal + +from services.request_history_service import RequestHistoryService + + +class HistoryDetailLoadSignals(QObject): + """Delivers loaded entry payloads on the GUI thread (queued connection).""" + + finished = Signal(int, object) + + +class HistoryDetailLoadRunnable(QRunnable): + """Read body/snapshot files and build detail snapshot off the GUI thread.""" + + def __init__( + self, + signals: HistoryDetailLoadSignals, + generation: int, + entry_id: int, + ) -> None: + """Store job parameters for :meth:`run`.""" + super().__init__() + self.setAutoDelete(True) + self._signals = signals + self._generation = generation + self._entry_id = entry_id + + def run(self) -> None: + """Load entry files and emit ``(generation, {entry, detail})`` or ``None``.""" + entry = RequestHistoryService.get_entry(self._entry_id) + if entry is None: + self._signals.finished.emit(self._generation, None) + return + detail = RequestHistoryService.entry_to_detail_snapshot(entry) + self._signals.finished.emit(self._generation, {"entry": entry, "detail": detail}) + + +class HistoryDetailLoader(QObject): + """Schedule :class:`HistoryDetailLoadRunnable` jobs (one result at a time).""" + + finished = Signal(int, object) + + def __init__(self, parent: QObject | None = None) -> None: + """Create a loader owned by *parent* (typically :class:`HistoryPanel`).""" + super().__init__(parent) + self._signals = HistoryDetailLoadSignals(self) + self._signals.finished.connect(self._forward_finished) + self._active_generation: int | None = None + + def cancel(self) -> None: + """Ignore in-flight results (pool workers are not interrupted).""" + self._active_generation = None + + def load(self, entry_id: int, generation: int) -> None: + """Start loading *entry_id*; emit ``finished(generation, payload)`` when done.""" + self._active_generation = generation + runnable = HistoryDetailLoadRunnable(self._signals, generation, entry_id) + QThreadPool.globalInstance().start(runnable) + + def _forward_finished(self, generation: int, payload: object) -> None: + if generation != self._active_generation: + return + self.finished.emit(generation, payload) diff --git a/src/ui/sidebar/history/global_mode/__init__.py b/src/ui/sidebar/history/global_mode/__init__.py new file mode 100644 index 0000000..64a99cc --- /dev/null +++ b/src/ui/sidebar/history/global_mode/__init__.py @@ -0,0 +1,6 @@ +"""Left-rail global mode for :class:`HistoryPanel`.""" + +from ui.sidebar.history.global_mode.mixin import _HistoryPanelGlobalMixin +from ui.sidebar.history.global_mode.open_filter import _HistoryTreeOpenFilter + +__all__ = ["_HistoryPanelGlobalMixin", "_HistoryTreeOpenFilter"] diff --git a/src/ui/sidebar/history/global_mode/mixin.py b/src/ui/sidebar/history/global_mode/mixin.py new file mode 100644 index 0000000..28a6843 --- /dev/null +++ b/src/ui/sidebar/history/global_mode/mixin.py @@ -0,0 +1,120 @@ +"""Global (left-rail) mode helpers for :class:`HistoryPanel`.""" + +# Mixin methods use attributes defined on :class:`HistoryPanel`. +# mypy: disable-error-code=attr-defined + +from __future__ import annotations + +from typing import Literal, cast + +from PySide6.QtGui import QAction +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QSplitter, + QWidget, +) + +from ui.styling.icons import phi +from ui.styling.theme import COLOR_WHITE + + +class _HistoryPanelGlobalMixin: + """Global workspace history: list all sends and emit open navigation requests.""" + + _mode: Literal["request", "global"] + _global_header: QWidget | None + _detail_host: QWidget + _detail_header_row: QHBoxLayout + _content_splitter: QSplitter + _open_btn: QPushButton + + def _init_global_mode_state(self) -> None: + """Set mode defaults; call from :class:`HistoryPanel` ``__init__``.""" + self._mode = "request" + self._global_header = None + + def set_global_mode(self) -> None: + """Configure this panel for the left-rail workspace history flyout.""" + self._mode = "global" + self.setObjectName("globalHistoryPanel") + self._request_id = None + self._request_name = "" + self._is_persisted_request = False + self._ensure_global_header() + self._place_date_filter_btn_for_mode() + self._replay_btn.hide() + self._open_btn.hide() + self._apply_global_list_only_layout(True) + self.refresh() + + def set_request_mode(self) -> None: + """Restore right-rail per-request mode (objectName and chrome).""" + self._mode = "request" + self.setObjectName("requestHistoryPanel") + self._open_btn.hide() + self._replay_btn.show() + self._apply_global_list_only_layout(False) + if self._global_header is not None: + self._global_header.hide() + self._place_date_filter_btn_for_mode() + + def _is_global_mode(self) -> bool: + return self._mode == "global" + + def _ensure_global_header(self) -> None: + """Add title + refresh row for the left flyout.""" + if self._global_header is not None: + self._global_header.show() + return + header = QWidget() + header.setObjectName("globalHistoryHeader") + row = QHBoxLayout(header) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(6) + title = QLabel("History") + title.setObjectName("sectionLabel") + row.addWidget(title) + row.addStretch(1) + if self._refresh_btn.parent() is not None: + self._refresh_btn.setParent(None) + row.addWidget(self._refresh_btn) + root = self.layout() + if root is not None: + root.insertWidget(0, header) + self._global_header = header + self._refresh_btn.setEnabled(True) + self._refresh_btn.show() + self._place_date_filter_btn_for_mode() + + def _apply_global_list_only_layout(self, list_only: bool) -> None: + """Hide the read-only detail stack on the left rail; list-only navigation.""" + self._detail_host.setVisible(not list_only) + if list_only: + self._content_splitter.setSizes([10_000, 0]) + else: + self._content_splitter.setSizes([180, 280]) + + def _build_open_button(self) -> QPushButton: + """Hidden placeholder; global open is click-to-navigate on the tree.""" + btn = QPushButton() + btn.setIcon(phi("arrow-square-out", color=COLOR_WHITE, size=16)) + btn.setObjectName("requestHistoryOpenButton") + btn.setFixedSize(28, 28) + btn.hide() + return btn + + def _emit_entry_open(self, entry_id: int) -> None: + """Emit :attr:`entry_open_requested` when in global mode.""" + if self._is_global_mode(): + self.entry_open_requested.emit(entry_id) + + def _global_empty_message(self) -> str: + return "No send history yet.\n\nSend a request to record an entry here." + + def _global_context_menu_actions(self, entry_id: int) -> list[QAction]: + host = cast(QWidget, self) + open_action = QAction("Open", host) + open_action.triggered.connect(lambda: self._emit_entry_open(entry_id)) + return [open_action] diff --git a/src/ui/sidebar/history/global_mode/open_filter.py b/src/ui/sidebar/history/global_mode/open_filter.py new file mode 100644 index 0000000..e41bfba --- /dev/null +++ b/src/ui/sidebar/history/global_mode/open_filter.py @@ -0,0 +1,41 @@ +"""Enter-key open filter for global history tree rows.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import QEvent, QObject, Qt +from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import QTreeWidget + +from ui.sidebar.history.delegate import ROLE_HISTORY_IS_DATE_GROUP + +if TYPE_CHECKING: + from ui.sidebar.history.panel import HistoryPanel + + +class _HistoryTreeOpenFilter(QObject): + """Emit open requests when Enter is pressed on a send row in the history tree.""" + + def __init__(self, panel: HistoryPanel, tree: QTreeWidget) -> None: + super().__init__(tree) + self._panel = panel + self._tree = tree + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + """Handle Return/Enter on a focused send row.""" + if watched is not self._tree or event.type() != QEvent.Type.KeyPress: + return False + if not isinstance(event, QKeyEvent): + return False + key = event.key() + if key not in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + return False + item = self._tree.currentItem() + if item is None or item.data(0, ROLE_HISTORY_IS_DATE_GROUP): + return False + entry_id = item.data(0, Qt.ItemDataRole.UserRole) + if isinstance(entry_id, int): + self._panel._emit_entry_open(entry_id) + return True + return False diff --git a/src/ui/sidebar/history/helpers.py b/src/ui/sidebar/history/helpers.py index b505105..75fe761 100644 --- a/src/ui/sidebar/history/helpers.py +++ b/src/ui/sidebar/history/helpers.py @@ -15,6 +15,7 @@ ROLE_HISTORY_IS_DATE_GROUP, ROLE_HISTORY_META, ROLE_HISTORY_NAME, + ROLE_HISTORY_URL, ) from ui.sidebar.saved_responses.helpers import ( extract_snapshot_headers, @@ -101,8 +102,10 @@ def populate_history_tree_widget( row.setData(0, Qt.ItemDataRole.UserRole, entry_id) row.setData(0, ROLE_HISTORY_CODE, item.get("status_code")) row.setData(0, ROLE_HISTORY_NAME, build_row_name(item)) + url = build_history_row_url(item) + row.setData(0, ROLE_HISTORY_URL, url) row.setData(0, ROLE_HISTORY_META, build_history_row_meta(item)) - row.setToolTip(0, build_row_name(item)) + row.setToolTip(0, build_history_row_tooltip(item)) group.setExpanded(True) tree.expandAll() @@ -149,6 +152,20 @@ def extract_history_request_headers(snapshot: Mapping[str, Any] | None) -> str: return extract_snapshot_headers(snapshot) +def build_history_row_url(entry: Mapping[str, Any]) -> str: + """Return the request URL for the secondary list line (elided when painted).""" + return str(entry.get("url", "")).strip() + + +def build_history_row_tooltip(entry: Mapping[str, Any]) -> str: + """Tooltip with full name and URL for a send-history row.""" + name = build_row_name(entry) + url = build_history_row_url(entry) + if url: + return f"{name}\n{url}" + return name + + def build_history_row_meta(entry: Mapping[str, Any]) -> str: """Return a metadata summary line for a send-history list row.""" parts: list[str] = [] diff --git a/src/ui/sidebar/history/panel.py b/src/ui/sidebar/history/panel.py index 7cace1c..cdc3b0c 100644 --- a/src/ui/sidebar/history/panel.py +++ b/src/ui/sidebar/history/panel.py @@ -2,9 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -from PySide6.QtCore import QPoint, Qt, Signal +from PySide6.QtCore import QPoint, Qt, QTimer, Signal from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QAbstractItemView, @@ -14,6 +14,7 @@ QLabel, QLineEdit, QMenu, + QProgressBar, QPushButton, QFrame, QStackedWidget, @@ -39,6 +40,9 @@ populate_history_tree_widget, ) from ui.sidebar.history.panel_detail_tabs import _HistoryPanelDetailTabsMixin +from ui.sidebar.history.detail import HistoryDetailLoader +from ui.sidebar.history.date_filter.mixin import _HistoryDateFilterMixin +from ui.sidebar.history.global_mode import _HistoryPanelGlobalMixin, _HistoryTreeOpenFilter from ui.sidebar.history.search_filter import _PanelSearchFilterMixin from ui.sidebar.saved_responses.helpers import ( detect_body_language, @@ -46,30 +50,37 @@ extract_snapshot_method, extract_snapshot_url, format_body_size, - format_code_text, format_headers, ) from ui.styling.icons import phi from ui.styling.theme import COLOR_WHITE, method_color, status_color from ui.widgets.code_editor import CodeEditorWidget +from ui.widgets.text_format_async import AsyncTextFormatRunner, skip_inline_text_for_async_pretty if TYPE_CHECKING: from services.request_history_service import RequestHistoryEntryDict -class HistoryPanel(_HistoryPanelDetailTabsMixin, _PanelSearchFilterMixin, QWidget): - """Read-only list/detail panel for HTTP send history on a request tab.""" +class HistoryPanel( # type: ignore[misc] + _HistoryPanelGlobalMixin, + _HistoryDateFilterMixin, + _HistoryPanelDetailTabsMixin, + _PanelSearchFilterMixin, + QWidget, +): + """Read-only list/detail panel for HTTP send history (per-request or global).""" refresh_requested = Signal() replay_requested = Signal(int) delete_requested = Signal(int) + entry_open_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.setObjectName("requestHistoryPanel") - self._request_id: int | None = None + self._request_id: int | None = None # type: ignore[assignment] self._request_name: str = "" self._is_persisted_request: bool = False self._items: list[RequestHistoryEntryDict] = [] @@ -82,6 +93,22 @@ def __init__(self, parent: QWidget | None = None) -> None: self._req_body_language: str = "text" self._body_view_mode: str = "Pretty" self._req_body_view_mode: str = "Pretty" + self._init_global_mode_state() + self._body_format_generation = 0 + self._req_body_format_generation = 0 + self._body_format_runner = AsyncTextFormatRunner(self) + self._body_format_runner.formatted.connect(self._on_async_response_body_formatted) + self._req_body_format_runner = AsyncTextFormatRunner(self) + self._req_body_format_runner.formatted.connect(self._on_async_request_body_formatted) + self._detail_load_generation = 0 + self._pending_detail_generation = 0 + self._pending_detail_entry: RequestHistoryEntryDict | None = None + self._pending_detail_snapshot: dict[str, Any] | None = None + self._detail_apply_timer = QTimer(self) + self._detail_apply_timer.setSingleShot(True) + self._detail_apply_timer.timeout.connect(self._apply_pending_detail_load) + self._detail_loader = HistoryDetailLoader(self) + self._detail_loader.finished.connect(self._on_detail_load_finished) root = QVBoxLayout(self) root.setContentsMargins(8, 4, 8, 8) @@ -106,7 +133,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self._history_search_input.setClearButtonEnabled(True) self._history_search_input.textChanged.connect(self._on_history_search_changed) self._history_search_input.hide() - root.addWidget(self._history_search_input) + self._init_date_filter_state() + self._search_row.hide() + root.addWidget(self._search_row) self._content_splitter = QSplitter(Qt.Orientation.Vertical) self._content_splitter.setChildrenCollapsible(False) @@ -147,12 +176,14 @@ def __init__(self, parent: QWidget | None = None) -> None: self._content_splitter.addWidget(self._list_stack) - detail_host = QWidget() + self._detail_host = QWidget() + detail_host = self._detail_host detail_layout = QVBoxLayout(detail_host) detail_layout.setContentsMargins(0, 0, 0, 0) detail_layout.setSpacing(6) - detail_header = QHBoxLayout() + self._detail_header_row = QHBoxLayout() + detail_header = self._detail_header_row detail_header.setContentsMargins(0, 0, 0, 0) detail_header.setSpacing(6) @@ -178,7 +209,9 @@ def __init__(self, parent: QWidget | None = None) -> None: detail_header.addLayout(summary_col, 1) self._replay_btn = self._make_replay_btn() + self._open_btn = self._build_open_button() detail_header.addWidget(self._replay_btn) + detail_header.addWidget(self._open_btn) detail_layout.addLayout(detail_header) @@ -207,6 +240,14 @@ def __init__(self, parent: QWidget | None = None) -> None: self._request_info_widget.hide() detail_layout.addWidget(self._request_info_widget) + self._detail_loading_bar = QProgressBar() + self._detail_loading_bar.setObjectName("requestHistoryDetailLoading") + self._detail_loading_bar.setRange(0, 0) + self._detail_loading_bar.setFixedHeight(4) + self._detail_loading_bar.setTextVisible(False) + self._detail_loading_bar.hide() + detail_layout.addWidget(self._detail_loading_bar) + self._detail_tabs = QTabWidget() self._detail_tabs.tabBar().setCursor(Qt.CursorShape.PointingHandCursor) detail_layout.addWidget(self._detail_tabs, 1) @@ -219,6 +260,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self._content_splitter.addWidget(detail_host) self._content_splitter.setSizes([180, 280]) + self._tree_open_filter = _HistoryTreeOpenFilter(self, self._tree_widget) + self._tree_widget.installEventFilter(self._tree_open_filter) + self.clear() def refresh_button(self) -> QPushButton: @@ -235,12 +279,16 @@ def _on_tree_context_menu(self, pos: QPoint) -> None: return menu = QMenu(self) - replay_action = QAction("Replay this request", self) - replay_action.triggered.connect(lambda: self.replay_requested.emit(entry_id)) - delete_action = QAction("Delete item", self) - delete_action.triggered.connect(lambda: self.delete_requested.emit(entry_id)) - menu.addAction(replay_action) - menu.addAction(delete_action) + if self._is_global_mode(): + for action in self._global_context_menu_actions(entry_id): + menu.addAction(action) + else: + replay_action = QAction("Replay this request", self) + replay_action.triggered.connect(lambda: self.replay_requested.emit(entry_id)) + delete_action = QAction("Delete item", self) + delete_action.triggered.connect(lambda: self.delete_requested.emit(entry_id)) + menu.addAction(replay_action) + menu.addAction(delete_action) menu.exec(self._tree_widget.viewport().mapToGlobal(pos)) def _on_replay_clicked(self) -> None: @@ -267,47 +315,79 @@ def _show_full_panel_empty_state(self, message: str) -> None: self._state_label.setText(message) self._state_label.show() self._history_search_input.hide() + self._search_row.hide() self._content_splitter.hide() self._set_detail_enabled(False) + def _set_browse_controls_enabled(self, enabled: bool) -> None: + """Enable or disable search + date filter (browse chrome only).""" + self._history_search_input.setEnabled(enabled) + self._date_filter_btn.setEnabled(enabled) + def _show_browse_layout(self) -> None: - """Show search, list, and detail (hide full-panel empty message).""" + """Show search and list; detail pane only in per-request mode.""" self._state_label.hide() self._history_search_input.show() + self._search_row.show() self._content_splitter.show() + self._set_browse_controls_enabled(True) + if self._is_global_mode(): + self._apply_global_list_only_layout(True) + else: + self._apply_global_list_only_layout(False) def show_request_required_state(self, message: str) -> None: """Show a contextual empty state when history is unavailable.""" self._show_full_panel_empty_state(message) self._refresh_btn.setEnabled(False) + self._set_browse_controls_enabled(False) def show_empty_history_state(self) -> None: - """Show the empty state for a persisted request with no sends yet.""" + """Show the empty state when there are no sends for the current scope.""" + if self._is_global_mode(): + self._show_full_panel_empty_state(self._global_empty_message()) + self._refresh_btn.setEnabled(True) + return self._show_full_panel_empty_state( "No history for this request yet.\n\nSend the request to record an entry here." ) self._refresh_btn.setEnabled(self._request_id is not None) - def refresh(self, search: str = "") -> None: - """Reload send history for the current persisted request.""" + def refresh(self, search: str = "", *, load_detail: bool = True) -> None: + """Reload send history for the current scope (request or global).""" + term = search if search else self._history_search_input.text().strip() + date_kw = self._date_filter_kwargs() + if self._is_global_mode(): + items = RequestHistoryService.list_for_sidebar(search=term, **date_kw) + self._apply_items(items, load_detail=load_detail) + return if not self._is_persisted_request or self._request_id is None: return - term = search if search else self._history_search_input.text().strip() - items = RequestHistoryService.list_for_request(self._request_id, search=term) - self._apply_items(items) + items = RequestHistoryService.list_for_request(self._request_id, search=term, **date_kw) + self._apply_items(items, load_detail=load_detail) def _on_history_search_changed(self, text: str) -> None: """Filter the list when the search box changes.""" + if self._is_global_mode(): + self.refresh(search=text.strip()) + return if not self._is_persisted_request or self._request_id is None: return self.refresh(search=text.strip()) - def _apply_items(self, items: list[RequestHistoryEntryDict]) -> None: + def _apply_items( + self, + items: list[RequestHistoryEntryDict], + *, + load_detail: bool = True, + ) -> None: """Populate the list from metadata rows.""" self._items = items self._items_by_id = {int(item["id"]): item for item in items if "id" in item} - self._refresh_btn.setEnabled(self._request_id is not None) - self._history_search_input.setEnabled(True) + if self._is_global_mode(): + self._refresh_btn.setEnabled(True) + else: + self._refresh_btn.setEnabled(self._request_id is not None) if not items: self._current_entry_id = None @@ -318,6 +398,11 @@ def _apply_items(self, items: list[RequestHistoryEntryDict]) -> None: self._list_empty_label.setText(f'No history matches "{term}".') self._list_stack.setCurrentIndex(0) self._set_detail_enabled(False) + elif self._date_filter_active(): + self._show_browse_layout() + self._list_empty_label.setText(f"No sends in {self._date_filter_summary()}.") + self._list_stack.setCurrentIndex(0) + self._set_detail_enabled(False) else: self.show_empty_history_state() return @@ -328,10 +413,22 @@ def _apply_items(self, items: list[RequestHistoryEntryDict]) -> None: self._list_stack.setCurrentWidget(self._tree_widget) first_id = first_history_entry_id(self._tree_widget) target_id = self._current_entry_id if self._current_entry_id in self._items_by_id else None - self._select_entry(target_id or first_id) + entry_id = target_id or first_id + self._select_entry(entry_id, load_detail=False) + if load_detail and entry_id is not None and not self._is_global_mode(): + self._schedule_detail_load(entry_id) def clear(self) -> None: """Reset the panel to its no-request state.""" + self._detail_apply_timer.stop() + self._pending_detail_entry = None + self._pending_detail_snapshot = None + self._detail_loader.cancel() + self._detail_load_generation += 1 + self._body_format_runner.cancel() + self._req_body_format_runner.cancel() + self._body_format_generation += 1 + self._req_body_format_generation += 1 self._request_id = None self._request_name = "" self._is_persisted_request = False @@ -340,7 +437,8 @@ def clear(self) -> None: self._current_entry_id = None self._tree_widget.clear() self._history_search_input.clear() - self._history_search_input.setEnabled(False) + self._set_browse_controls_enabled(False) + self._reset_date_filter() self._body_raw_text = "" self._body_language = "text" self._snapshot_raw_data = None @@ -372,9 +470,11 @@ def clear(self) -> None: self._detail_name.setText("Select a send") self._detail_meta.setText("") self._reset_search_filter() - self.show_request_required_state( - "Open a saved request to browse history for this request." - ) + if self._is_global_mode(): + self._show_full_panel_empty_state(self._global_empty_message()) + self._refresh_btn.setEnabled(True) + return + self.show_request_required_state("Open a saved request to browse history for this request.") def _restore_tree_selection_to_current_entry(self) -> bool: """Re-select the active send row after a date-group row receives focus.""" @@ -389,14 +489,18 @@ def _restore_tree_selection_to_current_entry(self) -> bool: return True def _on_tree_item_clicked(self, item: QTreeWidgetItem, _column: int) -> None: - """Toggle date groups on single click without changing the active send.""" - if not item.data(0, ROLE_HISTORY_IS_DATE_GROUP): + """Global: open send on click. Request mode: expand date groups only.""" + if item.data(0, ROLE_HISTORY_IS_DATE_GROUP): + item.setExpanded(not item.isExpanded()) + self._restore_tree_selection_to_current_entry() return - item.setExpanded(not item.isExpanded()) - self._restore_tree_selection_to_current_entry() + if self._is_global_mode(): + entry_id = item.data(0, Qt.ItemDataRole.UserRole) + if isinstance(entry_id, int): + self._emit_entry_open(entry_id) - def focus_entry(self, entry_id: int) -> bool: - """Select *entry_id* in the tree, expand its date group, and load detail.""" + def focus_entry(self, entry_id: int, *, load_detail: bool = True) -> bool: + """Select *entry_id* in the tree; optionally load the read-only detail pane.""" if not self._is_persisted_request or self._request_id is None: return False if entry_id not in self._items_by_id: @@ -411,22 +515,30 @@ def focus_entry(self, entry_id: int) -> bool: item, QAbstractItemView.ScrollHint.EnsureVisible, ) - self._select_entry(entry_id) + self._select_entry(entry_id, load_detail=load_detail) return True - def _select_entry(self, entry_id: int | None) -> None: - """Select a tree row and load detail for *entry_id*.""" + def _select_entry(self, entry_id: int | None, *, load_detail: bool = True) -> None: + """Select a tree row and optionally load detail for *entry_id*.""" if entry_id is None: self._set_detail_enabled(False) return item = find_history_tree_item(self._tree_widget, entry_id) if item is not None: - self._tree_widget.setCurrentItem(item) - self._load_detail(entry_id) + self._tree_widget.blockSignals(True) + try: + self._tree_widget.setCurrentItem(item) + finally: + self._tree_widget.blockSignals(False) + self._current_entry_id = entry_id + if load_detail and not self._is_global_mode(): + self._load_detail(entry_id) + elif not load_detail: + self._update_replay_button_enabled() return first_id = first_history_entry_id(self._tree_widget) if first_id is not None: - self._select_entry(first_id) + self._select_entry(first_id, load_detail=load_detail) return self._set_detail_enabled(False) @@ -449,10 +561,81 @@ def _on_selection_changed( self._set_detail_enabled(False) return self._current_entry_id = entry_id - self._load_detail(entry_id) + if self._is_global_mode(): + self._update_replay_button_enabled() + return + self._schedule_detail_load(entry_id) + + def schedule_detail_load(self, entry_id: int) -> None: + """Select a row and load its detail pane after the UI has settled (e.g. global open).""" + if not self.focus_entry(entry_id, load_detail=False): + return + self._schedule_detail_load(entry_id) + + def _schedule_detail_load(self, entry_id: int) -> None: + """Show loading chrome and fetch full payloads on a worker thread.""" + if self._is_global_mode(): + return + self._detail_apply_timer.stop() + self._detail_loader.cancel() + self._detail_load_generation += 1 + generation = self._detail_load_generation + self._current_entry_id = entry_id + self._show_detail_loading(entry_id) + self._detail_loader.load(entry_id, generation) + + def _show_detail_loading(self, entry_id: int) -> None: + """Show indeterminate progress while detail payloads load.""" + row = self._items_by_id.get(entry_id) + if row is not None: + self._detail_name.setText(build_row_name(row)) + self._detail_meta.setText("Loading response…") + else: + self._detail_name.setText("Loading…") + self._detail_meta.setText("") + self._status_badge.setText("") + self._status_badge.setStyleSheet("") + self._request_info_widget.hide() + self._detail_loading_bar.show() + self._detail_tabs.hide() + self._detail_tabs.setEnabled(False) + self._update_replay_button_enabled() + + def _hide_detail_loading(self) -> None: + """Restore detail tabs after loading completes or fails.""" + self._detail_loading_bar.hide() + self._detail_tabs.show() + + def _on_detail_load_finished(self, generation: int, payload: object) -> None: + """Apply async detail load result when still the active job.""" + if generation != self._detail_load_generation: + return + self._hide_detail_loading() + if not isinstance(payload, dict): + self._set_detail_enabled(False) + return + entry = payload.get("entry") + detail = payload.get("detail") + if not isinstance(entry, dict) or not isinstance(detail, dict): + self._set_detail_enabled(False) + return + self._pending_detail_generation = generation + self._pending_detail_entry = cast("RequestHistoryEntryDict", entry) + self._pending_detail_snapshot = detail + self._detail_apply_timer.start(0) + + def _apply_pending_detail_load(self) -> None: + """Apply a deferred detail payload when the event loop has settled.""" + if self._pending_detail_generation != self._detail_load_generation: + return + entry = self._pending_detail_entry + detail = self._pending_detail_snapshot + if entry is None or detail is None: + return + self._populate_detail(entry, detail) def _load_detail(self, entry_id: int) -> None: - """Fetch full entry payloads and render the detail pane.""" + """Fetch full entry payloads and render the detail pane (sync; tests only).""" entry = RequestHistoryService.get_entry(entry_id) if entry is None: self._set_detail_enabled(False) @@ -555,19 +738,16 @@ def _populate_detail( self._update_replay_button_enabled() def _update_replay_button_enabled(self) -> None: - """Enable replay when the selected row has a URL to send.""" - btn = getattr(self, "_replay_btn", None) - if btn is None: - return + """Enable replay/open when the selected row has a URL to send.""" entry = ( self._items_by_id.get(self._current_entry_id) if self._current_entry_id is not None else None ) - if entry is None: - btn.setEnabled(False) - return - btn.setEnabled(RequestHistoryService.can_replay_entry(entry)) + can_open = entry is not None and RequestHistoryService.can_replay_entry(entry) + replay = getattr(self, "_replay_btn", None) + if replay is not None and not self._is_global_mode(): + replay.setEnabled(can_open) def _set_detail_enabled(self, enabled: bool) -> None: """Enable or disable detail tabs.""" @@ -629,6 +809,8 @@ def _refresh_body_view(self, _mode: str | None = None) -> None: """Render the response body using the selected view mode.""" self._body_view_mode = self._body_view_combo.currentText() if not self._body_raw_text: + self._body_format_runner.cancel() + self._body_format_generation += 1 self._body_edit.hide() self._body_empty_label.show() return @@ -637,20 +819,40 @@ def _refresh_body_view(self, _mode: str | None = None) -> None: 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) + self._body_format_runner.cancel() + self._body_format_generation += 1 + generation = self._body_format_generation if self._is_filtered and self._filter_expression: self._run_filter(self._filter_expression, body_text) + if self._body_view_mode == "Pretty": + self._body_format_runner.format_async( + generation, + body_text, + language, + pretty=True, + ) return - self._body_edit.set_text(body_text) + if not skip_inline_text_for_async_pretty( + body_text, pretty=self._body_view_mode == "Pretty" + ): + self._body_edit.set_text(body_text) + if self._body_view_mode == "Pretty": + self._body_format_runner.format_async( + generation, + body_text, + language, + pretty=True, + ) def _refresh_request_body_view(self, _mode: str | None = None) -> None: """Render the request snapshot body using the selected view mode.""" self._req_body_view_mode = self._req_body_view_combo.currentText() if not self._req_body_raw_text: + self._req_body_format_runner.cancel() + self._req_body_format_generation += 1 self._req_body_edit.hide() self._req_body_empty_label.show() return @@ -659,10 +861,36 @@ def _refresh_request_body_view(self, _mode: str | None = None) -> None: self._req_body_edit.show() language = self._req_body_language or "text" body_text = self._req_body_raw_text - if self._req_body_view_mode == "Pretty": - body_text = format_code_text(body_text, language, pretty=True) self._req_body_edit.set_language(language) - self._req_body_edit.set_text(body_text) + self._req_body_format_runner.cancel() + self._req_body_format_generation += 1 + generation = self._req_body_format_generation + if not skip_inline_text_for_async_pretty( + body_text, pretty=self._req_body_view_mode == "Pretty" + ): + self._req_body_edit.set_text(body_text) + if self._req_body_view_mode == "Pretty": + self._req_body_format_runner.format_async( + generation, + body_text, + language, + pretty=True, + ) + + def _on_async_response_body_formatted(self, generation: int, text: str) -> None: + """Apply async pretty-print for the response body tab.""" + if generation != self._body_format_generation: + return + if self._is_filtered and self._filter_expression: + self._run_filter(self._filter_expression, text) + return + self._body_edit.set_text(text) + + def _on_async_request_body_formatted(self, generation: int, text: str) -> None: + """Apply async pretty-print for the request snapshot body tab.""" + if generation != self._req_body_format_generation: + return + self._req_body_edit.set_text(text) @staticmethod def _set_combo_text(combo: QComboBox, text: str) -> None: diff --git a/src/ui/sidebar/left_sidebar.py b/src/ui/sidebar/left_sidebar.py index 0ffacd8..ec119e9 100644 --- a/src/ui/sidebar/left_sidebar.py +++ b/src/ui/sidebar/left_sidebar.py @@ -38,8 +38,13 @@ _COLLECTIONS_KEY = "collections" _LOCAL_SCRIPTS_KEY = "local_scripts" +_HISTORY_KEY = "history" # Stacked flyout page order (left → right in internal stack indices). -_FLYOUT_PAGE_ORDER: tuple[str, ...] = (_COLLECTIONS_KEY, _LOCAL_SCRIPTS_KEY) +_FLYOUT_PAGE_ORDER: tuple[str, ...] = ( + _COLLECTIONS_KEY, + _LOCAL_SCRIPTS_KEY, + _HISTORY_KEY, +) # Local stylesheet when the flyout splitter width is 0. Qt often does not apply # ``[collapsed="true"]`` from a Python ``bool`` dynamic property, so borders @@ -196,11 +201,17 @@ def __init__(self, parent: QWidget | None = None) -> None: self._local_scripts_btn = self._make_rail_button("code", "Local scripts & snippets") self._local_scripts_btn.setVisible(False) rail_layout.addWidget(self._local_scripts_btn) + + self._history_btn = self._make_rail_button("clock-counter-clockwise", "History") + self._history_btn.setVisible(False) + rail_layout.addWidget(self._history_btn) + rail_layout.addStretch() self._buttons: dict[str, QToolButton] = { _COLLECTIONS_KEY: self._collections_btn, _LOCAL_SCRIPTS_KEY: self._local_scripts_btn, + _HISTORY_KEY: self._history_btn, } self._active_panel: str | None = None @@ -216,6 +227,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self._local_scripts_btn.clicked.connect( lambda: self._toggle_panel(_LOCAL_SCRIPTS_KEY), ) + self._history_btn.clicked.connect( + lambda: self._toggle_panel(_HISTORY_KEY), + ) self._flyout.set_chrome_sync(self._sync_left_flyout_chrome) @@ -230,6 +244,11 @@ def set_local_scripts_panel(self, widget: QWidget) -> None: self._flyout.set_panel(_LOCAL_SCRIPTS_KEY, widget) self._local_scripts_btn.setVisible(True) + def set_history_panel(self, widget: QWidget) -> None: + """Register the workspace **History** flyout page and show its rail icon.""" + self._flyout.set_panel(_HISTORY_KEY, widget) + self._history_btn.setVisible(True) + def install_in_splitter(self, splitter: QSplitter) -> None: """Insert the rail and flyout as the leftmost children of *splitter*. diff --git a/src/ui/sidebar/sidebar_widget.py b/src/ui/sidebar/sidebar_widget.py index 057465d..a664da1 100644 --- a/src/ui/sidebar/sidebar_widget.py +++ b/src/ui/sidebar/sidebar_widget.py @@ -39,6 +39,24 @@ if TYPE_CHECKING: from services.environment_service import LocalOverride, VariableDetail +_RAIL_TOOLTIP_SAVED_RESPONSES = "Saved responses" +_RAIL_TOOLTIP_HISTORY = "History" +_RAIL_TOOLTIP_SAVED_DRAFT = "Save the request to the collection first." +_RAIL_TOOLTIP_HISTORY_DRAFT = "Save the request to the collection first." +_RAIL_TOOLTIP_SAVED_ORPHAN = ( + "Saved responses belong to a request in the collection. " + "The original request was deleted — save this tab to the collection to use saved responses." +) +_RAIL_TOOLTIP_HISTORY_ORPHAN = ( + "Per-request history belongs to a saved request. " + "The original request was deleted — save this tab or use workspace History on the left." +) + + +def _rail_tooltip(enabled: bool, enabled_tip: str, disabled_tip: str) -> str: + """Return the rail-button tooltip for *enabled* vs disabled state.""" + return enabled_tip if enabled else disabled_tip + # ------------------------------------------------------------------ # Flyout panel — separate splitter child @@ -160,8 +178,8 @@ def __init__( "Variables", ) self._snippet_btn = self._make_rail_button("code", "Code snippet") - self._saved_btn = self._make_rail_button("floppy-disk-back", "Saved responses") - self._history_btn = self._make_rail_button("clock-counter-clockwise", "History") + self._saved_btn = self._make_rail_button("floppy-disk-back", _RAIL_TOOLTIP_SAVED_RESPONSES) + self._history_btn = self._make_rail_button("clock-counter-clockwise", _RAIL_TOOLTIP_HISTORY) self._snippet_btn.hide() self._saved_btn.hide() self._history_btn.hide() @@ -343,18 +361,31 @@ def set_saved_response_context( items: list[SavedResponseDict], can_save_current: bool, is_persisted_request: bool, + from_deleted_request_history: bool = False, ) -> None: """Populate the saved responses panel for the active request context.""" self._saved_btn.setVisible(True) self._saved_btn.setEnabled(is_persisted_request) + disabled_tip = ( + _RAIL_TOOLTIP_SAVED_ORPHAN + if from_deleted_request_history + else _RAIL_TOOLTIP_SAVED_DRAFT + ) + self._saved_btn.setToolTip( + _rail_tooltip(is_persisted_request, _RAIL_TOOLTIP_SAVED_RESPONSES, disabled_tip) + ) 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." + empty_msg = ( + "The original request was deleted from the collection. " + "Save this tab to the collection to store and browse saved responses." + if from_deleted_request_history + else "Save the request first to store and browse saved responses." ) + self._saved_responses_panel.show_request_required_state(empty_msg) return self._saved_responses_panel.set_saved_responses(items) @@ -364,11 +395,27 @@ def set_request_history_context( request_id: int | None, request_name: str | None, is_persisted_request: bool, + load_detail: bool = True, + from_deleted_request_history: bool = False, ) -> None: """Populate the send-history panel for the active request context.""" self._history_btn.setVisible(True) self._history_btn.setEnabled(is_persisted_request) - self._request_history_panel.set_request_context( + disabled_tip = ( + _RAIL_TOOLTIP_HISTORY_ORPHAN + if from_deleted_request_history + else _RAIL_TOOLTIP_HISTORY_DRAFT + ) + self._history_btn.setToolTip( + _rail_tooltip(is_persisted_request, _RAIL_TOOLTIP_HISTORY, disabled_tip) + ) + panel = self._request_history_panel + context_unchanged = ( + panel._request_id == request_id + and panel._is_persisted_request == is_persisted_request + and is_persisted_request + ) + panel.set_request_context( request_id, request_name, is_persisted_request=is_persisted_request, @@ -376,11 +423,16 @@ def set_request_history_context( if not is_persisted_request: if self._active_panel == "request_history": self._close_panel() - self._request_history_panel.show_request_required_state( - "Save the request first to browse history for this request." + empty_msg = ( + "The original request was deleted from the collection. " + "Use workspace History on the left, or save this tab to browse per-request history." + if from_deleted_request_history + else "Save the request first to browse history for this request." ) + panel.show_request_required_state(empty_msg) return - self._request_history_panel.refresh() + if not context_unchanged: + panel.refresh(load_detail=load_detail) def clear(self) -> None: """Reset the sidebar to an empty state (no tab open).""" diff --git a/src/ui/styling/global_qss.py b/src/ui/styling/global_qss.py index 9f0535c..b25516d 100644 --- a/src/ui/styling/global_qss.py +++ b/src/ui/styling/global_qss.py @@ -1381,27 +1381,39 @@ def build_global_qss(p: ThemePalette) -> str: color: {p["text"]}; }} + #globalHistoryPanel QStackedWidget[objectName="requestHistoryList"], + #requestHistoryPanel QStackedWidget[objectName="requestHistoryList"], QStackedWidget[objectName="requestHistoryList"] {{ border: 1px solid {p["border"]}; background: {p["input_bg"]}; border-radius: 4px; }} + #globalHistoryPanel QFrame[objectName="requestHistoryListEmpty"], + #requestHistoryPanel QFrame[objectName="requestHistoryListEmpty"], QFrame[objectName="requestHistoryListEmpty"] {{ background: transparent; border: none; }} + #globalHistoryPanel QTreeWidget[objectName="requestHistoryTree"], + #requestHistoryPanel QTreeWidget[objectName="requestHistoryTree"], QTreeWidget[objectName="requestHistoryTree"] {{ border: none; background: transparent; outline: none; }} + #globalHistoryPanel QTreeWidget[objectName="requestHistoryTree"]::item, + #requestHistoryPanel QTreeWidget[objectName="requestHistoryTree"]::item, QTreeWidget[objectName="requestHistoryTree"]::item {{ padding: 6px 8px; border: none; }} + #globalHistoryPanel QTreeWidget[objectName="requestHistoryTree"]::item:hover, + #requestHistoryPanel QTreeWidget[objectName="requestHistoryTree"]::item:hover, QTreeWidget[objectName="requestHistoryTree"]::item:hover {{ background: {p["hover_tree_bg"]}; }} + #globalHistoryPanel QTreeWidget[objectName="requestHistoryTree"]::item:selected, + #requestHistoryPanel QTreeWidget[objectName="requestHistoryTree"]::item:selected, QTreeWidget[objectName="requestHistoryTree"]::item:selected {{ background: {p["selected_bg"]}; color: {p["text"]}; diff --git a/src/ui/widgets/text_format_async/__init__.py b/src/ui/widgets/text_format_async/__init__.py new file mode 100644 index 0000000..1210fc4 --- /dev/null +++ b/src/ui/widgets/text_format_async/__init__.py @@ -0,0 +1,21 @@ +"""Async pretty-print helpers for large response bodies.""" + +from ui.widgets.text_format_async.helpers import ( + LARGE_INLINE_TEXT_CHARS, + skip_inline_text_for_async_pretty, +) +from ui.widgets.text_format_async.runner import AsyncTextFormatRunner +from ui.widgets.text_format_async.worker import ( + FormatTextRunnable, + FormatTextSignals, + FormatTextWorker, +) + +__all__ = [ + "LARGE_INLINE_TEXT_CHARS", + "AsyncTextFormatRunner", + "FormatTextRunnable", + "FormatTextSignals", + "FormatTextWorker", + "skip_inline_text_for_async_pretty", +] diff --git a/src/ui/widgets/text_format_async/helpers.py b/src/ui/widgets/text_format_async/helpers.py new file mode 100644 index 0000000..28946bd --- /dev/null +++ b/src/ui/widgets/text_format_async/helpers.py @@ -0,0 +1,11 @@ +"""Shared thresholds for async text formatting in the UI.""" + +from __future__ import annotations + +# Above this size, skip a synchronous ``set_text`` before async Pretty format. +LARGE_INLINE_TEXT_CHARS = 32_768 + + +def skip_inline_text_for_async_pretty(text: str, *, pretty: bool) -> bool: + """Return True when pretty-print should run off-thread without a sync preview.""" + return pretty and len(text) > LARGE_INLINE_TEXT_CHARS diff --git a/src/ui/widgets/text_format_async/runner.py b/src/ui/widgets/text_format_async/runner.py new file mode 100644 index 0000000..388ae31 --- /dev/null +++ b/src/ui/widgets/text_format_async/runner.py @@ -0,0 +1,49 @@ +"""Schedules :class:`FormatTextRunnable` jobs and returns results on the GUI thread.""" + +from __future__ import annotations + +from PySide6.QtCore import QObject, QThreadPool, Signal + +from ui.widgets.text_format_async.worker import FormatTextRunnable, FormatTextSignals + + +class AsyncTextFormatRunner(QObject): + """Run :func:`format_code_text` asynchronously via the global thread pool.""" + + formatted = Signal(int, str) + + def __init__(self, parent: QObject | None = None) -> None: + """Create a runner owned by *parent* (typically a widget).""" + super().__init__(parent) + self._signals = FormatTextSignals(self) + self._signals.finished.connect(self._on_worker_finished) + self._active_generation: int | None = None + + def cancel(self) -> None: + """Ignore results from in-flight jobs (pool workers are not interrupted).""" + self._active_generation = None + + def format_async( + self, + generation: int, + text: str, + language: str, + *, + pretty: bool, + ) -> None: + """Pretty-print *text* on a worker thread; emit ``formatted(generation, result)``.""" + self._active_generation = generation + runnable = FormatTextRunnable( + self._signals, + generation, + text, + language, + pretty=pretty, + ) + QThreadPool.globalInstance().start(runnable) + + def _on_worker_finished(self, generation: int, text: str) -> None: + """Forward formatted text when this job is still the active one.""" + if generation != self._active_generation: + return + self.formatted.emit(generation, text) diff --git a/src/ui/widgets/text_format_async/worker.py b/src/ui/widgets/text_format_async/worker.py new file mode 100644 index 0000000..3c032fc --- /dev/null +++ b/src/ui/widgets/text_format_async/worker.py @@ -0,0 +1,47 @@ +"""Background runnable that pretty-prints response bodies off the GUI thread.""" + +from __future__ import annotations + +from PySide6.QtCore import QObject, QRunnable, Signal + +from ui.sidebar.saved_responses.helpers import format_code_text + + +class FormatTextSignals(QObject): + """Cross-thread delivery of formatted text (queued to the GUI thread).""" + + finished = Signal(int, str) + + +class FormatTextRunnable(QRunnable): + """Run :func:`format_code_text` on a thread-pool worker.""" + + def __init__( + self, + signals: FormatTextSignals, + generation: int, + text: str, + language: str, + *, + pretty: bool, + ) -> None: + """Store payload; *signals* must live on the GUI thread.""" + super().__init__() + self.setAutoDelete(True) + self._signals = signals + self._generation = generation + self._text = text + self._language = language + self._pretty = pretty + + def run(self) -> None: + """Format text and emit the result with the job *generation* id.""" + try: + formatted = format_code_text(self._text, self._language, pretty=self._pretty) + except Exception: + formatted = self._text + self._signals.finished.emit(self._generation, formatted) + + +# Backward-compatible alias for tests. +FormatTextWorker = FormatTextRunnable diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 33cffe7..5ebef3c 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -278,6 +278,9 @@ tests/ │ ├── test_snippets_sidebar_panel.py │ ├── test_saved_responses_panel.py │ ├── test_request_history_panel.py + │ ├── test_global_history_panel.py + │ ├── test_left_sidebar_global_history.py + │ ├── test_global_history_open_navigation.py │ └── test_right_sidebar_request_history.py ├── collections/ # Collection sidebar tests │ ├── test_collection_header.py diff --git a/tests/ui/collections/test_collection_tree_actions.py b/tests/ui/collections/test_collection_tree_actions.py index ca0be93..334016f 100644 --- a/tests/ui/collections/test_collection_tree_actions.py +++ b/tests/ui/collections/test_collection_tree_actions.py @@ -101,6 +101,46 @@ def test_overview_action_emits_folder_open(self, qapp: QApplication, qtbot) -> N assert blocker.args == ["folder", 7, "Open"] +class TestCollectionTreeDuplicateAction: + """Tests for the Duplicate context-menu action on requests.""" + + def test_duplicate_action_exists_in_request_menu(self, qapp: QApplication, qtbot) -> None: + """The request context menu includes a Duplicate action.""" + tree = CollectionTree() + qtbot.addWidget(tree) + data_names = [a.data() for a in tree._request_menu.actions()] + assert "Duplicate" in data_names + + def test_duplicate_action_emits_request_duplicate_requested( + self, qapp: QApplication, qtbot + ) -> None: + """Triggering Duplicate emits request_duplicate_requested with the request ID.""" + tree = CollectionTree() + qtbot.addWidget(tree) + + data = make_collection_dict( + [ + { + "id": 1, + "name": "Coll", + "requests": [{"id": 42, "name": "Req", "method": "GET"}], + }, + ] + ) + tree.set_collections(data) + + folder = top_level_items(tree)[0] + req_item = folder.child(0) + tree._current_item = req_item + + duplicate_action = next(a for a in tree._request_menu.actions() if a.data() == "Duplicate") + + with qtbot.waitSignal(tree.request_duplicate_requested, timeout=1000) as blocker: + tree._emit_menu_action(duplicate_action) + + assert blocker.args == [42] + + class TestCollectionTreeRunAction: """Tests for the Run context-menu action on folders.""" diff --git a/tests/ui/request/test_response_viewer.py b/tests/ui/request/test_response_viewer.py index 8958cad..b610ae5 100644 --- a/tests/ui/request/test_response_viewer.py +++ b/tests/ui/request/test_response_viewer.py @@ -246,8 +246,12 @@ def test_format_pretty_json(self, qapp: QApplication, qtbot) -> None: viewer._format_combo.setCurrentText("Pretty") viewer.load_response(_make_response(body='{"a":1,"b":2}', elapsed_ms=5.0, size_bytes=13)) + def _pretty_ready() -> bool: + text = viewer._body_edit.toPlainText() + return "\n" in text and '"a": 1' in text + + qtbot.waitUntil(_pretty_ready, timeout=5000) body = viewer._body_edit.toPlainText() - # Pretty-printed JSON has newlines assert "\n" in body assert '"a": 1' in body @@ -278,6 +282,12 @@ def test_beautify_json(self, qapp: QApplication, qtbot) -> None: """Beautify formats compact JSON into indented output.""" viewer = self._make_viewer_with_body(qtbot, '{"a":1,"b":2}') viewer._on_beautify() + + def _pretty_ready() -> bool: + text = viewer._body_edit.toPlainText() + return "\n" in text and '"a": 1' in text + + qtbot.waitUntil(_pretty_ready, timeout=5000) body = viewer._body_edit.toPlainText() assert "\n" in body assert '"a": 1' in body @@ -287,6 +297,12 @@ def test_beautify_xml(self, qapp: QApplication, qtbot) -> None: xml_input = "val" viewer = self._make_viewer_with_body(qtbot, xml_input) viewer._on_beautify() + + def _pretty_ready() -> bool: + text = viewer._body_edit.toPlainText() + return "" in text and " " in text + + qtbot.waitUntil(_pretty_ready, timeout=5000) body = viewer._body_edit.toPlainText() assert "" in body assert " " in body # indented @@ -342,6 +358,41 @@ def test_save_no_response_noop(self, qapp: QApplication, qtbot) -> None: assert emitted == [] +class TestResponseViewerStoredResponse: + """Tests for load_stored_response (history / non-live display).""" + + def test_stored_response_not_saveable(self, qapp: QApplication, qtbot) -> None: + """Stored history responses do not enable Save Response.""" + viewer = ResponseViewerWidget() + qtbot.addWidget(viewer) + viewer.load_response(_make_response(body="live")) + assert viewer.has_live_response() + viewer.load_stored_response(_make_response(body="stored")) + assert not viewer.has_live_response() + assert not viewer._save_response_btn.isEnabled() + assert "stored" in viewer._body_edit.toPlainText() + + def test_stored_response_hides_test_results_tab(self, qapp: QApplication, qtbot) -> None: + """Stored load clears script test output from a prior live response.""" + viewer = ResponseViewerWidget() + qtbot.addWidget(viewer) + viewer.load_response(_make_response(body="live")) + viewer.load_test_results([{"name": "check", "passed": True, "error": None}]) + assert viewer._tabs.isTabVisible(viewer._test_tab_index) + viewer.load_stored_response(_make_response(body="stored")) + assert not viewer._tabs.isTabVisible(viewer._test_tab_index) + + def test_show_loading_clears_stored_body(self, qapp: QApplication, qtbot) -> None: + """Starting a send clears stored-history body text before loading UI.""" + viewer = ResponseViewerWidget() + qtbot.addWidget(viewer) + viewer.load_stored_response(_make_response(body="from-history")) + assert "from-history" in viewer._body_edit.toPlainText() + viewer.show_loading() + assert viewer._body_edit.toPlainText() == "" + assert not viewer.has_live_response() + + class TestResponseViewerPopups: """Tests for the click-triggered popup panels.""" diff --git a/tests/ui/sidebar/test_global_history_open_navigation.py b/tests/ui/sidebar/test_global_history_open_navigation.py new file mode 100644 index 0000000..7aa520d --- /dev/null +++ b/tests/ui/sidebar/test_global_history_open_navigation.py @@ -0,0 +1,375 @@ +"""Tests for opening workspace history entries into request tabs.""" + +from __future__ import annotations + +from PySide6.QtWidgets import QApplication + +from services.request_history_service import RequestHistoryService +from ui.main_window import MainWindow + + +class TestGlobalHistoryOpenNavigation: + """MainWindow navigation from global history.""" + + def test_open_existing_request_focuses_tab_not_center_response( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Opening a persisted send focuses the tab and right History, not centre viewer.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + ) + from tests.ui.conftest import finish_main_window_startup + from ui.styling.history_settings_manager import HistorySettingsManager + + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://nav.example", "NavReq") + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "NavReq", + "method": "GET", + "url": "http://nav.example", + }, + response={ + "status_code": 418, + "elapsed_ms": 3.0, + "headers": [{"key": "X-Test", "value": "1"}], + "body": "teapot", + }, + original_request={ + "method": "GET", + "url": "http://nav.example", + "name": "NavReq", + }, + settings=settings, + ) + assert entry_id is not None + window = MainWindow() + qtbot.addWidget(window) + finish_main_window_startup(window) + window._run_open_from_global_history(entry_id) + qtbot.wait(100) + ctx = window._tab_context_for_request_id(req.id) + assert ctx is not None + viewer = ctx.response_viewer + assert viewer is not None + assert "teapot" not in viewer._body_edit.toPlainText().lower() + assert window._right_sidebar.active_panel == "request_history" + assert window._request_history_panel._current_entry_id == entry_id + + def test_open_deleted_request_creates_draft( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Orphaned history opens a draft tab with editor prefilled.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + delete_request, + ) + from tests.ui.conftest import finish_main_window_startup + from ui.styling.history_settings_manager import HistorySettingsManager + + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://orphan.example", "Gone") + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Gone", + "method": "GET", + "url": "http://orphan.example", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "gone"}, + original_request={ + "method": "GET", + "url": "http://orphan.example", + "name": "Gone", + }, + settings=settings, + ) + delete_request(req.id) + window = MainWindow() + qtbot.addWidget(window) + finish_main_window_startup(window) + assert entry_id is not None + window._run_open_from_global_history(entry_id) + + def _orphan_url_ready() -> bool: + ctx = window._current_tab_context() + return ctx is not None and "orphan.example" in ctx.require_editor()._url_input.text() + + qtbot.waitUntil(_orphan_url_ready, timeout=5000) + ctx = window._current_tab_context() + assert ctx is not None + assert ctx.request_id is None + editor = ctx.require_editor() + assert editor.is_dirty + assert "orphan.example" in editor._url_input.text() + assert not window._right_sidebar._history_btn.isEnabled() + assert ctx.draft_name == "Gone" + crumb = window._breadcrumb_bar.last_segment_info + assert crumb is not None + assert crumb.get("name") == "Gone" + viewer = ctx.response_viewer + assert viewer is not None + assert not viewer.has_live_response() + assert not viewer._save_response_btn.isEnabled() + + def test_open_deleted_request_resolves_collection_variables_in_editor( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Orphan draft tabs push collection variables into the request editor.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + delete_request, + ) + from services.environment_service import EnvironmentService + from tests.ui.conftest import finish_main_window_startup + from ui.styling.history_settings_manager import HistorySettingsManager + + coll = create_new_collection("C") + EnvironmentService.add_variable("collection", coll.id, "hg_api_key", "from-collection") + req = create_new_request(coll.id, "GET", "http://orphan-vars.example", "Gone") + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Gone", + "method": "GET", + "url": "http://orphan-vars.example", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "gone"}, + original_request={ + "method": "GET", + "url": "http://orphan-vars.example", + "name": "Gone", + "collection_id": coll.id, + "auth": { + "type": "bearer", + "bearer": [{"key": "token", "value": "{{hg_api_key}}", "enabled": True}], + }, + }, + settings=settings, + ) + delete_request(req.id) + window = MainWindow() + qtbot.addWidget(window) + finish_main_window_startup(window) + assert entry_id is not None + window._run_open_from_global_history(entry_id) + + def _vars_ready() -> bool: + ctx = window._current_tab_context() + if ctx is None or ctx.editor is None: + return False + var_map = ctx.editor._bearer_token_input._variable_map + return "hg_api_key" in var_map + + qtbot.waitUntil(_vars_ready, timeout=5000) + ctx = window._current_tab_context() + assert ctx is not None + assert ctx.variable_collection_id == coll.id + + def test_open_orphan_does_not_overwrite_draft_when_tab_limit_blocks( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """When draft creation fails, an existing draft tab is not overwritten.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from tests.ui.conftest import finish_main_window_startup + from ui.styling.history_settings_manager import HistorySettingsManager + + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "Orphan", + "method": "GET", + "url": "http://orphan-blocked.example", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "x"}, + original_request={ + "method": "GET", + "url": "http://orphan-blocked.example", + "name": "Orphan", + }, + settings=settings, + ) + assert entry_id is not None + window = MainWindow() + qtbot.addWidget(window) + finish_main_window_startup(window) + window._open_draft_request() + ctx = window._current_tab_context() + assert ctx is not None + editor = ctx.require_editor() + editor._url_input.setText("keep-this-draft") + monkeypatch.setattr(window, "_enforce_tab_limit_before_open", lambda: False) + window._run_open_from_global_history(entry_id) + qtbot.wait(50) + assert editor._url_input.text() == "keep-this-draft" + + def test_open_existing_blocked_while_sending( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Global open does not replace response while a send is in flight.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + ) + from tests.ui.conftest import finish_main_window_startup + from ui.styling.history_settings_manager import HistorySettingsManager + + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://sending.example", "Send") + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Send", + "method": "GET", + "url": "http://sending.example", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "ok"}, + original_request={"method": "GET", "url": "http://sending.example"}, + settings=settings, + ) + assert entry_id is not None + window = MainWindow() + qtbot.addWidget(window) + finish_main_window_startup(window) + window._open_request(req.id, push_history=False) + ctx = window._tab_context_for_request_id(req.id) + assert ctx is not None + ctx.is_sending = True + window._run_open_from_global_history(entry_id) + qtbot.wait(50) + viewer = ctx.response_viewer + assert viewer is not None + assert not viewer.has_live_response() + + def test_stale_request_id_opens_draft( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """When request_id is set but get_request fails, open as draft.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from tests.ui.conftest import finish_main_window_startup + from ui.styling.history_settings_manager import HistorySettingsManager + + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": 999_999, + "request_name": "Ghost", + "method": "GET", + "url": "http://ghost.example", + }, + response={"status_code": 404, "elapsed_ms": 1.0, "body": "nf"}, + original_request={ + "method": "GET", + "url": "http://ghost.example", + "name": "Ghost", + }, + settings=settings, + ) + assert entry_id is not None + window = MainWindow() + qtbot.addWidget(window) + finish_main_window_startup(window) + window._run_open_from_global_history(entry_id) + qtbot.wait(100) + ctx = window._current_tab_context() + assert ctx is not None + assert ctx.request_id is None + assert "ghost.example" in ctx.require_editor()._url_input.text() + + def test_record_send_refreshes_global_panel( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """on_send_finished refresh path updates the global history list.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from tests.ui.conftest import finish_main_window_startup + from ui.main_window.send_pipeline_postresponse import _record_request_history + from ui.styling.history_settings_manager import HistorySettingsManager + + window = MainWindow() + qtbot.addWidget(window) + finish_main_window_startup(window) + window._history_settings = HistorySettingsManager() + window._pending_history_context = { + "request_id": None, + "request_name": "", + "method": "POST", + "url": "http://refresh-global.example", + "tab_type": "request", + } + panel = window._global_history_panel + panel.refresh() + before = len(panel._items) + _record_request_history( + window, + None, + { + "status_code": 204, + "elapsed_ms": 2.0, + "body": "created", + "request_method": "POST", + "request_url": "http://refresh-global.example", + }, + ) + assert len(panel._items) == before + 1 diff --git a/tests/ui/sidebar/test_global_history_panel.py b/tests/ui/sidebar/test_global_history_panel.py new file mode 100644 index 0000000..f856dcb --- /dev/null +++ b/tests/ui/sidebar/test_global_history_panel.py @@ -0,0 +1,337 @@ +"""Tests for workspace-wide (left-rail) send history panel.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication +from PySide6.QtTest import QTest + +from services.request_history_service import RequestHistoryService +from ui.sidebar.history.helpers import find_history_tree_item +from ui.sidebar.history.panel import HistoryPanel + + +class TestGlobalHistoryPanel: + """HistoryPanel in global (left-rail) mode.""" + + def test_set_global_mode_object_name_and_empty(self, qapp: QApplication, qtbot) -> None: + """Global mode uses globalHistoryPanel and workspace empty copy.""" + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_global_mode() + assert panel.objectName() == "globalHistoryPanel" + assert "No send history yet" in panel._state_label.text() + assert panel._replay_btn.isHidden() + assert panel._open_btn.isHidden() + + def test_global_mode_hides_detail_pane( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Left-rail global history is list-only; no Body/Headers preview stack.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://list-only.example", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "preview-me"}, + original_request={"method": "GET", "url": "http://list-only.example"}, + settings=settings, + ) + assert entry_id is not None + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_global_mode() + panel.refresh() + assert not panel._detail_host.isVisible() + assert panel._body_edit.toPlainText() == "" + + def test_refresh_lists_all_sends( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Global refresh uses list_for_sidebar.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://draft.example", + }, + response={"status_code": 204, "elapsed_ms": 1.0, "body": "ok"}, + original_request={"method": "GET", "url": "http://draft.example"}, + settings=settings, + ) + assert entry_id is not None + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_global_mode() + panel.refresh() + assert panel._current_entry_id == entry_id + assert panel._items_by_id[entry_id]["source_label"] == "(draft)" + + def test_context_menu_open_only(self, qapp: QApplication, qtbot) -> None: + """Global mode context menu offers Open, not replay/delete.""" + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_global_mode() + with qtbot.waitSignal(panel.entry_open_requested, timeout=1000) as blocker: + panel._emit_entry_open(42) + assert blocker.args == [42] + + def test_enter_emits_open( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Enter on a focused send row emits entry_open_requested.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "POST", + "url": "http://enter.example", + }, + response={"status_code": 201, "elapsed_ms": 2.0, "body": "x"}, + original_request={"method": "POST", "url": "http://enter.example"}, + settings=settings, + ) + assert entry_id is not None + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_global_mode() + panel.refresh() + panel._tree_widget.setFocus() + with qtbot.waitSignal(panel.entry_open_requested, timeout=1000) as blocker: + QTest.keyClick(panel._tree_widget, Qt.Key.Key_Return) + assert blocker.args == [entry_id] + + def test_search_filters_by_url( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Global search filters the tree by URL substring.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + settings = HistorySettingsManager() + RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://alpha.example", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "a"}, + original_request=None, + settings=settings, + ) + beta_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://beta.example", + }, + response={"status_code": 201, "elapsed_ms": 1.0, "body": "b"}, + original_request=None, + settings=settings, + ) + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_global_mode() + panel.refresh() + panel._history_search_input.setText("beta") + qtbot.wait(50) + assert panel._current_entry_id == beta_id + + def test_single_click_emits_open( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Single-click on a send row emits entry_open_requested.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://click.example", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "x"}, + original_request=None, + settings=settings, + ) + assert entry_id is not None + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_global_mode() + panel.refresh() + item = find_history_tree_item(panel._tree_widget, entry_id) + assert item is not None + with qtbot.waitSignal(panel.entry_open_requested, timeout=1000) as blocker: + panel._on_tree_item_clicked(item, 0) + assert blocker.args == [entry_id] + + def test_global_context_menu_actions_open_only( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Global context menu builder exposes Open only (no replay/delete).""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://menu.example", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "x"}, + original_request=None, + settings=settings, + ) + assert entry_id is not None + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.set_global_mode() + actions = panel._global_context_menu_actions(entry_id) + assert len(actions) == 1 + assert actions[0].text() == "Open" + with qtbot.waitSignal(panel.entry_open_requested, timeout=1000) as blocker: + actions[0].trigger() + assert blocker.args == [entry_id] + + def test_date_filter_popup_applies_range( + self, + tmp_path, + monkeypatch, + qapp: QApplication, + qtbot, + ) -> None: + """Header filter button opens popup; Apply restricts the global list.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from datetime import UTC, datetime, timedelta + + from database.database import get_session + from database.models.request_history.model.request_history_entry_model import ( + RequestHistoryEntryModel, + ) + from ui.styling.history_settings_manager import HistorySettingsManager + + settings = HistorySettingsManager() + old_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://old-filter.example", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "x"}, + original_request=None, + settings=settings, + ) + new_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://new-filter.example", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "x"}, + original_request=None, + settings=settings, + ) + assert old_id is not None and new_id is not None + today = datetime.now(tz=UTC).date() + with get_session() as session: + old_row = session.get(RequestHistoryEntryModel, old_id) + assert old_row is not None + old_row.executed_at = datetime.combine( + today - timedelta(days=14), + datetime.min.time(), + tzinfo=UTC, + ) + session.commit() + + panel = HistoryPanel() + qtbot.addWidget(panel) + panel.show() + panel.set_global_mode() + panel.refresh() + assert panel._tree_widget.topLevelItemCount() >= 2 + + panel._toggle_date_range_filter(panel._date_filter_btn) + qtbot.waitUntil( + lambda: panel._date_filter_popup is not None and panel._date_filter_popup.isVisible(), + timeout=3000, + ) + popup = panel._date_filter_popup + assert popup is not None + from PySide6.QtCore import QDate + + popup._from_edit.setDate(QDate.currentDate().addDays(-1)) + popup._to_edit.setDate(QDate.currentDate()) + QTest.mouseClick(popup._apply_btn, Qt.MouseButton.LeftButton) + qtbot.waitUntil(lambda: panel._date_filter_btn.isChecked()) + + item = find_history_tree_item(panel._tree_widget, new_id) + assert item is not None + assert find_history_tree_item(panel._tree_widget, old_id) is None diff --git a/tests/ui/sidebar/test_left_sidebar_global_history.py b/tests/ui/sidebar/test_left_sidebar_global_history.py new file mode 100644 index 0000000..db87c83 --- /dev/null +++ b/tests/ui/sidebar/test_left_sidebar_global_history.py @@ -0,0 +1,26 @@ +"""Tests for the left-rail History flyout button.""" + +from __future__ import annotations + +from PySide6.QtWidgets import QApplication, QSplitter + +from ui.sidebar.history.panel import HistoryPanel +from ui.sidebar.left_sidebar import LeftSidebar + + +class TestLeftSidebarGlobalHistory: + """Left sidebar third rail button for workspace history.""" + + def test_history_panel_registered(self, qapp: QApplication, qtbot) -> None: + """set_history_panel reveals the History rail button.""" + splitter = QSplitter() + qtbot.addWidget(splitter) + left = LeftSidebar() + panel = HistoryPanel() + panel.set_global_mode() + left.set_history_panel(panel) + left.install_in_splitter(splitter) + assert not left._history_btn.isHidden() + left.open_panel("history") + assert left.active_panel == "history" + assert left.session_panel_key() == "history" diff --git a/tests/ui/sidebar/test_request_history_panel.py b/tests/ui/sidebar/test_request_history_panel.py index d62e30c..4cf86ac 100644 --- a/tests/ui/sidebar/test_request_history_panel.py +++ b/tests/ui/sidebar/test_request_history_panel.py @@ -101,7 +101,7 @@ def test_refresh_populates_tree_and_detail( assert _entry_count(panel._tree_widget) == 1 assert first_history_entry_id(panel._tree_widget) is not None assert "Example" in panel._detail_name.text() - assert "hello" in panel._body_edit.toPlainText() + qtbot.waitUntil(lambda: "hello" in panel._body_edit.toPlainText(), timeout=5000) def test_search_filters_by_status( self, diff --git a/tests/ui/sidebar/test_sidebar.py b/tests/ui/sidebar/test_sidebar.py index 3a4a608..754fb54 100644 --- a/tests/ui/sidebar/test_sidebar.py +++ b/tests/ui/sidebar/test_sidebar.py @@ -136,6 +136,30 @@ def test_show_request_panels_enables_both( assert not sidebar._saved_btn.isHidden() assert sidebar._saved_btn.isEnabled() + def test_orphan_history_tab_shows_rail_tooltips(self, qapp: QApplication, qtbot) -> None: + """Disabled saved/history rail icons explain deleted-request history tabs.""" + sidebar = RightSidebar() + qtbot.addWidget(sidebar) + sidebar.show_request_panels({}, method="GET", url="http://example.com") + sidebar.set_saved_response_context( + request_id=None, + request_name="Gone", + items=[], + can_save_current=False, + is_persisted_request=False, + from_deleted_request_history=True, + ) + sidebar.set_request_history_context( + request_id=None, + request_name="Gone", + is_persisted_request=False, + from_deleted_request_history=True, + ) + assert not sidebar._saved_btn.isEnabled() + assert "deleted" in sidebar._saved_btn.toolTip().lower() + assert not sidebar._history_btn.isEnabled() + assert "deleted" in sidebar._history_btn.toolTip().lower() + def test_show_folder_panels_disables_snippet( self, qapp: QApplication, diff --git a/tests/ui/test_main_window_session.py b/tests/ui/test_main_window_session.py index c698e3b..a100881 100644 --- a/tests/ui/test_main_window_session.py +++ b/tests/ui/test_main_window_session.py @@ -152,6 +152,18 @@ def test_persist_records_left_sidebar_panel(self, qapp: QApplication, qtbot) -> assert saved is not None assert saved.get("left_sidebar_panel") == "local_scripts" + def test_persist_records_history_left_sidebar_panel(self, qapp: QApplication, qtbot) -> None: + """_persist_open_tabs saves the History left-rail flyout page.""" + window = MainWindow() + qtbot.addWidget(window) + + window._left_sidebar.open_panel("history") + window._persist_open_tabs() + + saved = window._tab_settings_manager.load_open_tabs() + assert saved is not None + assert saved.get("left_sidebar_panel") == "history" + def test_persist_records_local_script_tabs(self, qapp: QApplication, qtbot) -> None: """_persist_open_tabs saves local script tab ids and names.""" from database.models.local_scripts.local_script_repository import ( @@ -277,6 +289,25 @@ def test_restore_opens_left_sidebar_panel(self, qapp: QApplication, qtbot) -> No assert window._left_sidebar.active_panel == "local_scripts" assert window._left_sidebar.is_open + def test_restore_opens_history_left_sidebar_panel(self, qapp: QApplication, qtbot) -> None: + """_restore_tabs reopens the persisted History left-rail flyout.""" + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [], + "active": 0, + "left_sidebar_panel": "history", + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + finish_main_window_startup(window) + + assert window._left_sidebar.active_panel == "history" + assert window._left_sidebar.is_open + def test_restore_opens_local_script_tabs(self, qapp: QApplication, qtbot) -> None: """_restore_tabs reopens local script tabs from the persisted session.""" from database.models.local_scripts.local_script_repository import ( diff --git a/tests/unit/database/test_repository.py b/tests/unit/database/test_repository.py index 8783cbb..4949cd5 100644 --- a/tests/unit/database/test_repository.py +++ b/tests/unit/database/test_repository.py @@ -13,19 +13,27 @@ get_request_by_id, get_request_variable_chain, get_request_variable_chain_detailed, + get_saved_responses_for_request, ) from database.models.collections.collection_repository import ( create_new_collection, create_new_request, delete_collection, delete_request, + duplicate_request, rename_collection, rename_request, + save_response, + unique_duplicate_request_name, update_collection, update_collection_parent, update_request, update_request_collection, ) +from database.models.request_assertions.request_assertion_repository import ( + fetch_assertions_for_request, + replace_assertions_for_request, +) # ------------------------------------------------------------------ @@ -126,6 +134,71 @@ def test_delete_nonexistent_request_raises(self): with pytest.raises(ValueError, match="No request found"): delete_request(99999) + def test_unique_duplicate_request_name(self) -> None: + """Copy suffix increments when sibling names already exist.""" + assert unique_duplicate_request_name("Search", set()) == "Search Copy" + assert unique_duplicate_request_name("Search", {"Search Copy"}) == "Search Copy 2" + assert ( + unique_duplicate_request_name( + "Search", + {"Search Copy", "Search Copy 2"}, + ) + == "Search Copy 3" + ) + + def test_duplicate_request_clones_fields_saved_responses_and_assertions(self) -> None: + """duplicate_request copies the full request payload into the same folder.""" + coll = create_new_collection("Coll") + source = create_new_request( + coll.id, + "POST", + "http://api.test", + "Source", + body='{"a":1}', + scripts={"test": "pm.test('x', () => {})"}, + auth={"type": "noauth"}, + ) + save_response(source.id, "Example", "OK", 200, [{"key": "X", "value": "1"}], '{"ok":true}') + replace_assertions_for_request( + source.id, + [ + { + "subject": "status", + "operator": "eq", + "expected": "200", + "enabled": True, + "order_index": 0, + } + ], + ) + + duplicate = duplicate_request(source.id) + + assert duplicate.id != source.id + assert duplicate.collection_id == coll.id + assert duplicate.name == "Source Copy" + assert duplicate.method == "POST" + assert duplicate.url == "http://api.test" + assert duplicate.body == '{"a":1}' + assert duplicate.scripts == {"test": "pm.test('x', () => {})"} + + responses = get_saved_responses_for_request(duplicate.id) + assert len(responses) == 1 + assert responses[0]["name"] == "Example" + assert responses[0]["body"] == '{"ok":true}' + + assertions = fetch_assertions_for_request(duplicate.id) + assert len(assertions) == 1 + assert assertions[0]["subject"] == "status" + assert assertions[0]["expected"] == "200" + + second = duplicate_request(source.id) + assert second.name == "Source Copy 2" + + def test_duplicate_nonexistent_request_raises(self) -> None: + with pytest.raises(ValueError, match="No request found"): + duplicate_request(99999) + def test_move_request_to_different_collection(self): coll_a = create_new_collection("A") coll_b = create_new_collection("B") diff --git a/tests/unit/database/test_request_history_repository.py b/tests/unit/database/test_request_history_repository.py index 924526a..0f6466e 100644 --- a/tests/unit/database/test_request_history_repository.py +++ b/tests/unit/database/test_request_history_repository.py @@ -174,3 +174,76 @@ def test_list_for_request_search_by_url_and_status(tmp_path, monkeypatch) -> Non by_status = request_history_repository.list_for_request(req.id, search="400") assert len(by_status) == 1 assert by_status[0]["status_code"] == 400 + + +def test_list_search_respects_limit(tmp_path, monkeypatch) -> None: + """Search queries apply the same row cap as unfiltered lists.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + for index in range(5): + _insert(method="GET", url=f"http://limit-{index}.example", request_name=f"N{index}") + rows = request_history_repository.list_entries_for_sidebar( + search="limit", + limit=2, + ) + assert len(rows) == 2 + + +def test_list_for_request_search_respects_limit(tmp_path, monkeypatch) -> None: + """Per-request search queries apply the row cap.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://req-limit.example", "R") + for index in range(4): + _insert( + request_id=req.id, + request_name="R", + url=f"http://req-limit-{index}.example", + ) + rows = request_history_repository.list_for_request( + req.id, + search="req-limit", + limit=2, + ) + assert len(rows) == 2 + + +def test_list_entries_for_sidebar_date_range(tmp_path, monkeypatch) -> None: + """Local calendar date bounds filter sidebar rows.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + old = _insert(url="http://old.example", request_name="old") + new = _insert(url="http://new.example", request_name="new") + from database.database import get_session + from database.models.request_history.model.request_history_entry_model import ( + RequestHistoryEntryModel, + ) + + today = datetime.now(tz=UTC).date() + with get_session() as session: + old_row = session.get(RequestHistoryEntryModel, int(old["id"])) + assert old_row is not None + old_row.executed_at = datetime.combine( + today - timedelta(days=10), + datetime.min.time(), + tzinfo=UTC, + ) + new_row = session.get(RequestHistoryEntryModel, int(new["id"])) + assert new_row is not None + new_row.executed_at = datetime.now(tz=UTC) + session.commit() + + filtered = request_history_repository.list_entries_for_sidebar( + executed_from=today - timedelta(days=1), + executed_to=today, + ) + ids = {int(row["id"]) for row in filtered} + assert int(new["id"]) in ids + assert int(old["id"]) not in ids diff --git a/tests/unit/services/test_environment_service.py b/tests/unit/services/test_environment_service.py index 304aef2..440b3e9 100644 --- a/tests/unit/services/test_environment_service.py +++ b/tests/unit/services/test_environment_service.py @@ -177,6 +177,20 @@ def test_only_collection_variables(self) -> None: result = EnvironmentService.build_combined_variable_map(None, req.id) assert result == {"host": "coll-host"} + def test_collection_id_without_request(self) -> None: + """Draft tabs can resolve variables via ``collection_id`` alone.""" + coll = create_new_collection("Root") + update_collection( + coll.id, + variables=[{"key": "token", "value": "from-coll", "enabled": True}], + ) + result = EnvironmentService.build_combined_variable_map( + None, + None, + collection_id=coll.id, + ) + assert result == {"token": "from-coll"} + def test_only_environment_variables(self) -> None: """Environment variables are returned when no request is set.""" env = EnvironmentService.create_environment( diff --git a/tests/unit/services/test_request_history_service.py b/tests/unit/services/test_request_history_service.py index c726331..a92cc72 100644 --- a/tests/unit/services/test_request_history_service.py +++ b/tests/unit/services/test_request_history_service.py @@ -39,3 +39,145 @@ def test_record_send_and_detail_snapshot(tmp_path, monkeypatch, qapp) -> None: detail = RequestHistoryService.entry_to_detail_snapshot(entry) assert detail["body"] == "created" assert detail["original_request"]["url"] == "http://example.com" + + +def test_entry_to_http_response_dict_success(tmp_path, monkeypatch, qapp) -> None: + """entry_to_http_response_dict maps stored payloads for the response viewer.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + settings = HistorySettingsManager() + identity: SendIdentityDict = { + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://example.com", + } + response = { + "status_code": 200, + "elapsed_ms": 5.0, + "headers": [{"key": "Content-Type", "value": "application/json"}], + "body": '{"ok":true}', + } + entry_id = RequestHistoryService.record_send( + identity=identity, + response=response, + original_request={"method": "GET", "url": "http://example.com"}, + settings=settings, + ) + assert entry_id is not None + entry = RequestHistoryService.get_entry(entry_id) + assert entry is not None + shaped = RequestHistoryService.entry_to_http_response_dict(entry) + assert shaped["status_code"] == 200 + assert shaped["status_text"] == "OK" + assert shaped["body"] == '{"ok":true}' + assert shaped["request_url"] == "http://example.com" + assert shaped["headers"][0]["key"] == "Content-Type" + + +def test_source_label_deleted_vs_draft(tmp_path, monkeypatch, qapp) -> None: + """was_persisted_request distinguishes (deleted) from (draft).""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + delete_request, + ) + + settings = HistorySettingsManager() + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://x", "Named") + persisted_id = RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Named", + "method": "GET", + "url": "http://x", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "a"}, + original_request={"method": "GET", "url": "http://x"}, + settings=settings, + ) + delete_request(req.id) + assert persisted_id is not None + deleted = RequestHistoryService.get_entry(persisted_id) + assert deleted is not None + assert deleted["source_label"] == "(deleted)" + + draft_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://y", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "b"}, + original_request=None, + settings=settings, + ) + assert draft_id is not None + draft = RequestHistoryService.get_entry(draft_id) + assert draft is not None + assert draft["source_label"] == "(draft)" + + +def test_source_label_none_for_persisted_request(tmp_path, monkeypatch, qapp) -> None: + """Persisted rows have no source_label suffix.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + from database.models.collections.collection_repository import ( + create_new_collection, + create_new_request, + ) + + settings = HistorySettingsManager() + coll = create_new_collection("C") + req = create_new_request(coll.id, "GET", "http://z", "Live") + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": req.id, + "request_name": "Live", + "method": "GET", + "url": "http://z", + }, + response={"status_code": 200, "elapsed_ms": 1.0, "body": "ok"}, + original_request={"method": "GET", "url": "http://z"}, + settings=settings, + ) + assert entry_id is not None + entry = RequestHistoryService.get_entry(entry_id) + assert entry is not None + assert entry.get("source_label") is None + + +def test_entry_to_http_response_dict_error(tmp_path, monkeypatch, qapp) -> None: + """Error sends map to load_stored_response error shape.""" + monkeypatch.setattr( + "database.data_paths.postmark_user_data_dir", + lambda: tmp_path / "postmark", + ) + settings = HistorySettingsManager() + entry_id = RequestHistoryService.record_send( + identity={ + "request_id": None, + "request_name": "", + "method": "GET", + "url": "http://fail.example", + }, + response={"error": "Connection refused", "elapsed_ms": 12.0}, + original_request={"method": "GET", "url": "http://fail.example"}, + settings=settings, + ) + assert entry_id is not None + entry = RequestHistoryService.get_entry(entry_id) + assert entry is not None + shaped = RequestHistoryService.entry_to_http_response_dict(entry) + assert shaped.get("error") == "Connection refused" + assert shaped.get("elapsed_ms") == 0.0 diff --git a/tests/unit/services/test_service.py b/tests/unit/services/test_service.py index 24331d3..65c40f2 100644 --- a/tests/unit/services/test_service.py +++ b/tests/unit/services/test_service.py @@ -201,6 +201,20 @@ def test_get_single_saved_response(self) -> None: assert response["id"] == sr_id assert response["request_id"] == req.id + def test_duplicate_request(self) -> None: + """duplicate_request clones the request and its saved responses.""" + svc = CollectionService() + coll = svc.create_collection("C") + req = svc.create_request(coll.id, "GET", "http://x", "Original") + svc.save_response(req.id, "Ex", "OK", 200, [], "body") + + dup = svc.duplicate_request(req.id) + + assert dup.id != req.id + assert dup.name == "Original Copy" + assert len(svc.get_saved_responses(dup.id)) == 1 + assert svc.get_saved_responses(dup.id)[0]["name"] == "Ex" + def test_rename_duplicate_and_delete(self) -> None: """Saved responses support rename, duplicate, and delete operations.""" svc = CollectionService() diff --git a/tests/unit/ui/widgets/test_text_format_async.py b/tests/unit/ui/widgets/test_text_format_async.py new file mode 100644 index 0000000..82430ee --- /dev/null +++ b/tests/unit/ui/widgets/test_text_format_async.py @@ -0,0 +1,35 @@ +"""Tests for async response body formatting.""" + +from __future__ import annotations + +from PySide6.QtWidgets import QApplication, QWidget + +from ui.widgets.text_format_async import ( + AsyncTextFormatRunner, + FormatTextRunnable, + FormatTextSignals, +) + + +def test_format_text_runnable_pretty_json(qapp: QApplication, qtbot) -> None: + """Runnable pretty-prints JSON on a thread-pool worker.""" + host = QWidget() + qtbot.addWidget(host) + signals = FormatTextSignals(host) + with qtbot.waitSignal(signals.finished, timeout=5000) as blocker: + runnable = FormatTextRunnable(signals, 1, '{"a":1}', "json", pretty=True) + runnable.run() + assert blocker.args[0] == 1 + assert '"a"' in blocker.args[1] + assert "\n" in blocker.args[1] + + +def test_async_runner_emits_on_gui_thread(qapp: QApplication, qtbot) -> None: + """Runner delivers formatted text via ``formatted`` on the main thread.""" + host = QWidget() + qtbot.addWidget(host) + runner = AsyncTextFormatRunner(host) + with qtbot.waitSignal(runner.formatted, timeout=5000) as blocker: + runner.format_async(3, '{"b":2}', "json", pretty=True) + assert blocker.args[0] == 3 + assert '"b"' in blocker.args[1] diff --git a/tests/unit/ui/widgets/test_text_format_helpers.py b/tests/unit/ui/widgets/test_text_format_helpers.py new file mode 100644 index 0000000..dab2e49 --- /dev/null +++ b/tests/unit/ui/widgets/test_text_format_helpers.py @@ -0,0 +1,18 @@ +"""Tests for async text-format helpers.""" + +from __future__ import annotations + +from ui.widgets.text_format_async.helpers import skip_inline_text_for_async_pretty + + +class TestSkipInlineTextForAsyncPretty: + """Tests for :func:`skip_inline_text_for_async_pretty`.""" + + def test_small_body_uses_inline(self) -> None: + assert not skip_inline_text_for_async_pretty("{}", pretty=True) + + def test_large_body_skips_inline_when_pretty(self) -> None: + assert skip_inline_text_for_async_pretty("x" * 40_000, pretty=True) + + def test_large_body_allows_inline_when_not_pretty(self) -> None: + assert not skip_inline_text_for_async_pretty("x" * 40_000, pretty=False) From 70c7cd591ec0f38084dfa44299b744fb8262297e Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Tue, 2 Jun 2026 16:46:03 +0300 Subject: [PATCH 4/7] fix: Update sidebar flyout panel behavior and styling - Enhanced the `sidebarPanelArea` documentation to clarify the layout and border behavior, specifically regarding the left and right edges. - Modified the `_show_panel` method in `RightSidebar` to include an `expand_flyout` parameter, allowing for more control over flyout expansion. - Adjusted the handling of flyout panel visibility during drag events to ensure consistent state management. - Updated global QSS styles to reflect the new border handling for the sidebar panel area, ensuring proper visual alignment with the icon rail. - Added a test to verify that collapsing the flyout via dragging clears the history rail highlight correctly. --- docs/ui-reference/sidebar.md | 9 +++- src/ui/AGENTS.md | 2 +- src/ui/sidebar/sidebar_widget.py | 36 ++++------------ src/ui/styling/global_qss.py | 6 ++- .../test_right_sidebar_request_history.py | 41 ++++++++++++++++++- 5 files changed, 59 insertions(+), 35 deletions(-) diff --git a/docs/ui-reference/sidebar.md b/docs/ui-reference/sidebar.md index 7c18be2..3335242 100644 --- a/docs/ui-reference/sidebar.md +++ b/docs/ui-reference/sidebar.md @@ -137,10 +137,15 @@ Always-visible fixed-width icon rail. ## FlyoutPanel -Collapsible content area as a splitter child. +Collapsible content area as a splitter child (`objectName` ``sidebarPanelArea``). Contains four stacked panels with a title bar and close button. -The flyout can snap closed via its splitter handle. +The flyout can snap closed via its splitter handle. The **left** edge against +the editor is the ``mainWindowHorizontalSplitter`` handle only (no flyout +``border-left`` — a full-height left border was hidden under ``QScrollArea`` +children and looked like a stray line beside the title row). The **right** +edge uses a flyout ``border-right`` (and the same on inner ``QScrollArea`` +widgets so the viewport does not paint over it) before the icon rail. ## VariablesPanel diff --git a/src/ui/AGENTS.md b/src/ui/AGENTS.md index 37f021c..30f5651 100644 --- a/src/ui/AGENTS.md +++ b/src/ui/AGENTS.md @@ -270,7 +270,7 @@ standard object names: | `sidebarRailButton` | `QToolButton` | Checkable icon button in the right rail | | `leftSidebarRail` | `QWidget` | Left activity rail: background uses palette ``status_bar_bg`` (same as ``QStatusBar#appStatusBar``); no outer layout padding | | `leftSidebarRailButton` | `QToolButton` | Rail icon (``_LeftRailButton``): width ``round(LEFT_RAIL_WIDTH_EM * em)``, icon ``round(LEFT_RAIL_ICON_EM * em)``, height ``icon_size + LEFT_RAIL_BUTTON_EXTRA_HEIGHT_PX``; checked left accent **painted** full height (``LEFT_RAIL_ACCENT_STRIPE_WIDTH_PX``); QSS margin/padding ``0`` | -| `sidebarPanelArea` | `QWidget` | Right sidebar collapsible flyout panel (separate splitter child) | +| `sidebarPanelArea` | `QWidget` | Right sidebar collapsible flyout panel (separate splitter child); ``border-right`` vs icon rail only — left edge is ``mainWindowHorizontalSplitter`` handle (no ``border-left``) | | `requestHistoryPanel` | `HistoryPanel` | Per-request History flyout (right rail, 4th button) | | `globalHistoryPanel` | `HistoryPanel` | Workspace History flyout (left rail, 3rd button; `set_global_mode`) | | `globalHistoryHeader` | `QWidget` | Left global History title row (History label + refresh) | diff --git a/src/ui/sidebar/sidebar_widget.py b/src/ui/sidebar/sidebar_widget.py index a664da1..7f2fd26 100644 --- a/src/ui/sidebar/sidebar_widget.py +++ b/src/ui/sidebar/sidebar_widget.py @@ -510,7 +510,7 @@ def _toggle_panel(self, panel: str) -> None: else: self._show_panel(panel) - def _show_panel(self, panel: str) -> None: + def _show_panel(self, panel: str, *, expand_flyout: bool = True) -> None: """Open *panel*, configuring the flyout content.""" self._active_panel = panel self._last_panel = panel @@ -531,7 +531,8 @@ def _show_panel(self, panel: str) -> None: self._title_label.setText(titles.get(panel, panel)) self._flyout._history_refresh_btn.setVisible(panel == "request_history") self._flyout.show() - self._expand_flyout() + if expand_flyout: + self._expand_flyout() def _close_panel(self) -> None: """Collapse the flyout, keeping the icon rail visible.""" @@ -581,36 +582,13 @@ def _on_splitter_moved(self, _pos: int, _index: int) -> None: flyout_width = self._splitter.sizes()[self._flyout_idx] if flyout_width == 0 and self._active_panel: - # User collapsed the flyout by dragging. - 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) + # User collapsed the flyout by dragging — same state as close/toggle. + self._close_panel() if flyout_width > 0 and not self._active_panel: - # User expanded the flyout by dragging — open a panel. + # User expanded the flyout by dragging — restore last panel content. panel = self._last_panel if not panel or panel not in self._available_panels: panel = self._default_panel if panel: - # Only configure content — don't call _expand_flyout - # again since the user is already controlling the width. - self._active_panel = panel - 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" - if panel == "snippet" - else "Saved Responses", - ) - self._flyout.show() + self._show_panel(panel, expand_flyout=False) diff --git a/src/ui/styling/global_qss.py b/src/ui/styling/global_qss.py index b25516d..afd5465 100644 --- a/src/ui/styling/global_qss.py +++ b/src/ui/styling/global_qss.py @@ -1422,10 +1422,12 @@ def build_global_qss(p: ThemePalette) -> str: /* ---- Right sidebar ------------------------------------------ */ QWidget[objectName="sidebarPanelArea"] {{ background: {p["bg"]}; - border-left: 1px solid {p["border"]}; + /* Left edge: mainWindowHorizontalSplitter handle only (no border-left — + a full-widget border-left showed only beside the title row because + QScrollArea children paint over it below the header). */ border-right: 1px solid {p["border"]}; }} - /* Scroll area inside expanded sidebar must not override parent's right border */ + /* Scroll area must not override the flyout's right border vs the icon rail */ QWidget[objectName="sidebarPanelArea"] QScrollArea {{ border: none; border-right: 1px solid {p["border"]}; diff --git a/tests/ui/sidebar/test_right_sidebar_request_history.py b/tests/ui/sidebar/test_right_sidebar_request_history.py index 7eaf5b8..116f4f2 100644 --- a/tests/ui/sidebar/test_right_sidebar_request_history.py +++ b/tests/ui/sidebar/test_right_sidebar_request_history.py @@ -2,7 +2,8 @@ from __future__ import annotations -from PySide6.QtWidgets import QApplication +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication, QSplitter, QWidget from ui.sidebar.history.panel import HistoryPanel from ui.sidebar.sidebar_widget import RightSidebar @@ -59,3 +60,41 @@ def test_open_request_history_panel(self, qapp: QApplication, qtbot) -> None: assert sidebar.active_panel == "request_history" assert sidebar._history_btn.isChecked() assert sidebar.request_history_panel.isVisible() + + def test_drag_collapse_clears_history_rail_highlight(self, qapp: QApplication, qtbot) -> None: + """Dragging the flyout shut must uncheck the history rail icon.""" + splitter = QSplitter(Qt.Orientation.Horizontal) + filler = QWidget() + filler.setMinimumWidth(320) + filler.setMinimumHeight(120) + + sidebar = RightSidebar() + qtbot.addWidget(splitter) + sidebar.install_in_splitter(splitter) + splitter.addWidget(filler) + splitter.resize(900, 400) + splitter.setSizes([400, 300, 50]) + splitter.show() + qapp.processEvents() + + sidebar.show_request_panels({}, method="GET", url="http://x") + sidebar.set_request_history_context( + request_id=1, + request_name="Req", + is_persisted_request=True, + ) + sidebar.open_panel("request_history") + qapp.processEvents() + assert sidebar._history_btn.isChecked() + flyout_idx = splitter.indexOf(sidebar._flyout) + + sizes = list(splitter.sizes()) + freed = sizes[flyout_idx] + sizes[0] += freed + sizes[flyout_idx] = 0 + splitter.setSizes(sizes) + sidebar._on_splitter_moved(0, flyout_idx) + qapp.processEvents() + + assert sidebar.active_panel is None + assert not sidebar._history_btn.isChecked() From c5ea2151ff2ab685cba067456a2c44a4464ce68c Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Tue, 2 Jun 2026 19:10:41 +0300 Subject: [PATCH 5/7] feat: Improve subprocess error handling and enhance IPC line parsing - Updated error reporting in the `_run_restricted_subprocess` function to include detailed information from the subprocess's stderr and exit code. - Introduced a new `_parse_ipc_line` function to handle parsing of IPC lines, specifically for identifying terminal `__done__` rows. - Modified the `_ipc_loop` function to improve handling of subprocess output and ensure proper termination conditions. - Added a `closeEvent` method in the `HistoryPanel` class to cancel in-flight operations during widget teardown. - Enhanced the `FormatTextRunnable` class to check the validity of signals before emitting results, preventing potential errors. --- src/services/scripting/py_runtime.py | 37 +++++++++++++++++-- src/ui/sidebar/history/panel.py | 9 ++++- src/ui/widgets/text_format_async/worker.py | 5 +++ .../test_global_history_open_navigation.py | 15 ++++++-- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/services/scripting/py_runtime.py b/src/services/scripting/py_runtime.py index 32281d9..6678620 100644 --- a/src/services/scripting/py_runtime.py +++ b/src/services/scripting/py_runtime.py @@ -231,11 +231,21 @@ def _run_restricted_subprocess(script: str, context: ScriptInput) -> ScriptOutpu if result is not None: _apply_result(result, output) else: + err_tail = "" + if proc.stderr is not None: + with contextlib.suppress(OSError, ValueError): + err_tail = proc.stderr.read().decode(errors="replace").strip() + detail = "Sandbox produced no output" + rc = proc.poll() + if rc not in (None, 0): + detail = f"Sandbox exited with code {rc}" + if err_tail: + detail = f"{detail}: {err_tail[:400]}" output["test_results"].append( { "name": "(runtime error)", "passed": False, - "error": "Sandbox produced no output", + "error": detail, "duration_ms": (time.monotonic() - start) * 1000, } ) @@ -262,6 +272,17 @@ def _run_restricted_subprocess(script: str, context: ScriptInput) -> ScriptOutpu return output +def _parse_ipc_line(line: bytes) -> dict[str, Any] | None: + """Parse one stdout line; return payload when it is a terminal ``__done__`` row.""" + try: + data: dict[str, Any] = json.loads(line) + except json.JSONDecodeError: + return None + if data.get("__done__"): + return data + return None + + def _ipc_loop(proc: subprocess.Popen[bytes]) -> dict[str, Any] | None: """Read lines from the sandbox, fulfilling IPC requests.""" from services.scripting.context import execute_sub_request @@ -271,10 +292,18 @@ def _ipc_loop(proc: subprocess.Popen[bytes]) -> dict[str, Any] | None: assert proc.stdin is not None total = 0 - while True: + deadline = time.monotonic() + _SUBPROCESS_TIMEOUT + while time.monotonic() < deadline: line = proc.stdout.readline() if not line: - return None + if proc.poll() is not None: + for raw in proc.stdout.read().splitlines(): + done = _parse_ipc_line(raw) + if done is not None: + return done + break + time.sleep(0.01) + continue try: data: dict[str, Any] = json.loads(line) @@ -295,6 +324,8 @@ def _ipc_loop(proc: subprocess.Popen[bytes]) -> dict[str, Any] | None: proc.stdin.write(json.dumps(resp).encode() + b"\n") proc.stdin.flush() + return None + def _kill_proc(proc: subprocess.Popen[bytes]) -> None: """Kill the subprocess (called from the timer thread).""" diff --git a/src/ui/sidebar/history/panel.py b/src/ui/sidebar/history/panel.py index cdc3b0c..4a13378 100644 --- a/src/ui/sidebar/history/panel.py +++ b/src/ui/sidebar/history/panel.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, cast from PySide6.QtCore import QPoint, Qt, QTimer, Signal -from PySide6.QtGui import QAction +from PySide6.QtGui import QAction, QCloseEvent from PySide6.QtWidgets import ( QAbstractItemView, QApplication, @@ -418,6 +418,13 @@ def _apply_items( if load_detail and entry_id is not None and not self._is_global_mode(): self._schedule_detail_load(entry_id) + def closeEvent(self, event: QCloseEvent) -> None: + """Cancel in-flight detail/format work before widget teardown.""" + self._detail_loader.cancel() + self._body_format_runner.cancel() + self._req_body_format_runner.cancel() + super().closeEvent(event) + def clear(self) -> None: """Reset the panel to its no-request state.""" self._detail_apply_timer.stop() diff --git a/src/ui/widgets/text_format_async/worker.py b/src/ui/widgets/text_format_async/worker.py index 3c032fc..bd019e0 100644 --- a/src/ui/widgets/text_format_async/worker.py +++ b/src/ui/widgets/text_format_async/worker.py @@ -3,6 +3,7 @@ from __future__ import annotations from PySide6.QtCore import QObject, QRunnable, Signal +from shiboken6 import isValid from ui.sidebar.saved_responses.helpers import format_code_text @@ -36,10 +37,14 @@ def __init__( def run(self) -> None: """Format text and emit the result with the job *generation* id.""" + if not isValid(self._signals): + return try: formatted = format_code_text(self._text, self._language, pretty=self._pretty) except Exception: formatted = self._text + if not isValid(self._signals): + return self._signals.finished.emit(self._generation, formatted) diff --git a/tests/ui/sidebar/test_global_history_open_navigation.py b/tests/ui/sidebar/test_global_history_open_navigation.py index 7aa520d..7d5ad52 100644 --- a/tests/ui/sidebar/test_global_history_open_navigation.py +++ b/tests/ui/sidebar/test_global_history_open_navigation.py @@ -58,14 +58,23 @@ def test_open_existing_request_focuses_tab_not_center_response( qtbot.addWidget(window) finish_main_window_startup(window) window._run_open_from_global_history(entry_id) - qtbot.wait(100) + qtbot.waitUntil( + lambda: window._tab_context_for_request_id(req.id) is not None, + timeout=5000, + ) ctx = window._tab_context_for_request_id(req.id) assert ctx is not None viewer = ctx.response_viewer assert viewer is not None assert "teapot" not in viewer._body_edit.toPlainText().lower() - assert window._right_sidebar.active_panel == "request_history" - assert window._request_history_panel._current_entry_id == entry_id + qtbot.waitUntil( + lambda: window._right_sidebar.active_panel == "request_history", + timeout=5000, + ) + qtbot.waitUntil( + lambda: window._request_history_panel._current_entry_id == entry_id, + timeout=5000, + ) def test_open_deleted_request_creates_draft( self, From 16b73ddc4e92fb0b7fb4eb0851ead6ac2a814c5d Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Tue, 2 Jun 2026 20:22:24 +0300 Subject: [PATCH 6/7] refactor: Transition to dedicated worker threads for history loading - Replaced the `Runnable` pattern with dedicated worker classes for loading orphan history and detail entries, improving thread management and signal handling. - Introduced shutdown methods in `OrphanHistoryOpenLoader` and `HistoryDetailLoader` to ensure proper cleanup of worker threads during widget teardown. - Updated `HistoryPanel` and `MainWindow` to call shutdown methods, preventing potential memory leaks and ensuring smooth application closure. - Added a new utility function for safely delivering results from worker threads to the GUI thread. --- .../history_navigation/orphan_open.py | 85 +++++++++++-------- src/ui/main_window/window.py | 2 + src/ui/sidebar/history/detail/loader.py | 83 +++++++++++------- src/ui/sidebar/history/panel.py | 2 +- src/ui/widgets/qt_thread_delivery.py | 25 ++++++ src/ui/widgets/text_format_async/worker.py | 13 ++- tests/ui/conftest.py | 3 + 7 files changed, 141 insertions(+), 72 deletions(-) create mode 100644 src/ui/widgets/qt_thread_delivery.py diff --git a/src/ui/main_window/history_navigation/orphan_open.py b/src/ui/main_window/history_navigation/orphan_open.py index 1745457..1e7b0ca 100644 --- a/src/ui/main_window/history_navigation/orphan_open.py +++ b/src/ui/main_window/history_navigation/orphan_open.py @@ -2,66 +2,83 @@ from __future__ import annotations -from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal +from PySide6.QtCore import QObject, QThread, Signal, Slot, QMetaObject, Qt, Q_ARG +from shiboken6 import isValid from services.request_history_service import RequestHistoryService -class OrphanHistoryOpenSignals(QObject): - """Delivers a loaded history entry on the GUI thread (queued connection).""" +class _OrphanHistoryOpenWorker(QObject): + """Loads a full history entry on a dedicated thread.""" finished = Signal(int, object) - -class OrphanHistoryOpenRunnable(QRunnable): - """Load a full history entry (body files + snapshot) off the GUI thread.""" - - def __init__( - self, - signals: OrphanHistoryOpenSignals, - generation: int, - entry_id: int, - ) -> None: - """Store job parameters for :meth:`run`.""" - super().__init__() - self.setAutoDelete(True) - self._signals = signals - self._generation = generation - self._entry_id = entry_id - - def run(self) -> None: - """Load entry files and emit ``(generation, payload)`` or ``(generation, None)``.""" - entry = RequestHistoryService.get_entry(self._entry_id) + @Slot(int, int) + def load_entry(self, generation: int, entry_id: int) -> None: + """Read files and emit ``finished(generation, payload)`` from the worker thread.""" + entry = RequestHistoryService.get_entry(entry_id) if entry is None: - self._signals.finished.emit(self._generation, None) + self.finished.emit(generation, None) return http = RequestHistoryService.entry_to_http_response_dict(entry) - self._signals.finished.emit(self._generation, {"entry": entry, "http": http}) + self.finished.emit(generation, {"entry": entry, "http": http}) class OrphanHistoryOpenLoader(QObject): - """Schedule :class:`OrphanHistoryOpenRunnable` jobs (one result at a time).""" + """Schedule :class:`_OrphanHistoryOpenWorker` jobs (one result at a time).""" finished = Signal(int, object) def __init__(self, parent: QObject | None = None) -> None: """Create a loader owned by *parent* (typically :class:`MainWindow`).""" super().__init__(parent) - self._signals = OrphanHistoryOpenSignals(self) - self._signals.finished.connect(self._forward_finished) + self._thread: QThread | None = None + self._worker: _OrphanHistoryOpenWorker | None = None self._active_generation: int | None = None + def _ensure_worker_thread(self) -> None: + """Start the worker thread on first use.""" + if self._thread is not None: + return + self._thread = QThread(self) + self._worker = _OrphanHistoryOpenWorker() + self._worker.moveToThread(self._thread) + assert self._worker is not None + self._worker.finished.connect( + self._on_worker_finished, + Qt.ConnectionType.QueuedConnection, + ) + self._thread.start() + def cancel(self) -> None: - """Ignore in-flight results (pool workers are not interrupted).""" + """Ignore in-flight results (the worker thread is not interrupted).""" self._active_generation = None + def shutdown(self) -> None: + """Stop the worker thread (call when the main window closes).""" + self.cancel() + thread = self._thread + if thread is None or not thread.isRunning(): + return + thread.quit() + thread.wait(5000) + def load(self, entry_id: int, generation: int) -> None: - """Start loading *entry_id*; emit ``finished(generation, entry)`` when done.""" + """Start loading *entry_id*; emit ``finished(generation, payload)`` when done.""" + self._ensure_worker_thread() self._active_generation = generation - runnable = OrphanHistoryOpenRunnable(self._signals, generation, entry_id) - QThreadPool.globalInstance().start(runnable) - - def _forward_finished(self, generation: int, payload: object) -> None: + assert self._worker is not None + QMetaObject.invokeMethod( # type: ignore[call-overload] + self._worker, + "load_entry", + Qt.ConnectionType.QueuedConnection, + Q_ARG(int, generation), + Q_ARG(int, entry_id), + ) + + def _on_worker_finished(self, generation: int, payload: object) -> None: if generation != self._active_generation: return + if not isValid(self): + return self.finished.emit(generation, payload) diff --git a/src/ui/main_window/window.py b/src/ui/main_window/window.py index e2425a1..f45e50d 100644 --- a/src/ui/main_window/window.py +++ b/src/ui/main_window/window.py @@ -872,6 +872,8 @@ def closeEvent(self, event: QCloseEvent) -> None: from services.scripting.engine import ScriptLinter ScriptLinter.shutdown() + self._orphan_history_open_loader.shutdown() + self._request_history_panel._detail_loader.shutdown() super().closeEvent(event) diff --git a/src/ui/sidebar/history/detail/loader.py b/src/ui/sidebar/history/detail/loader.py index 6901ef4..ed73625 100644 --- a/src/ui/sidebar/history/detail/loader.py +++ b/src/ui/sidebar/history/detail/loader.py @@ -2,66 +2,83 @@ from __future__ import annotations -from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal +from PySide6.QtCore import QObject, QThread, Signal, Slot, QMetaObject, Qt, Q_ARG +from shiboken6 import isValid from services.request_history_service import RequestHistoryService -class HistoryDetailLoadSignals(QObject): - """Delivers loaded entry payloads on the GUI thread (queued connection).""" +class _HistoryDetailLoadWorker(QObject): + """Loads history rows on a dedicated thread (``Signal(int, object)`` is thread-safe).""" finished = Signal(int, object) - -class HistoryDetailLoadRunnable(QRunnable): - """Read body/snapshot files and build detail snapshot off the GUI thread.""" - - def __init__( - self, - signals: HistoryDetailLoadSignals, - generation: int, - entry_id: int, - ) -> None: - """Store job parameters for :meth:`run`.""" - super().__init__() - self.setAutoDelete(True) - self._signals = signals - self._generation = generation - self._entry_id = entry_id - - def run(self) -> None: - """Load entry files and emit ``(generation, {entry, detail})`` or ``None``.""" - entry = RequestHistoryService.get_entry(self._entry_id) + @Slot(int, int) + def load_entry(self, generation: int, entry_id: int) -> None: + """Read files and emit ``finished(generation, payload)`` from the worker thread.""" + entry = RequestHistoryService.get_entry(entry_id) if entry is None: - self._signals.finished.emit(self._generation, None) + self.finished.emit(generation, None) return detail = RequestHistoryService.entry_to_detail_snapshot(entry) - self._signals.finished.emit(self._generation, {"entry": entry, "detail": detail}) + self.finished.emit(generation, {"entry": entry, "detail": detail}) class HistoryDetailLoader(QObject): - """Schedule :class:`HistoryDetailLoadRunnable` jobs (one result at a time).""" + """Schedule :class:`_HistoryDetailLoadWorker` jobs (one result at a time).""" finished = Signal(int, object) def __init__(self, parent: QObject | None = None) -> None: """Create a loader owned by *parent* (typically :class:`HistoryPanel`).""" super().__init__(parent) - self._signals = HistoryDetailLoadSignals(self) - self._signals.finished.connect(self._forward_finished) + self._thread: QThread | None = None + self._worker: _HistoryDetailLoadWorker | None = None self._active_generation: int | None = None + def _ensure_worker_thread(self) -> None: + """Start the worker thread on first use.""" + if self._thread is not None: + return + self._thread = QThread(self) + self._worker = _HistoryDetailLoadWorker() + self._worker.moveToThread(self._thread) + assert self._worker is not None + self._worker.finished.connect( + self._on_worker_finished, + Qt.ConnectionType.QueuedConnection, + ) + self._thread.start() + def cancel(self) -> None: - """Ignore in-flight results (pool workers are not interrupted).""" + """Ignore in-flight results (the worker thread is not interrupted).""" self._active_generation = None + def shutdown(self) -> None: + """Stop the worker thread (call before tearing down the parent widget).""" + self.cancel() + thread = self._thread + if thread is None or not thread.isRunning(): + return + thread.quit() + thread.wait(5000) + def load(self, entry_id: int, generation: int) -> None: """Start loading *entry_id*; emit ``finished(generation, payload)`` when done.""" + self._ensure_worker_thread() self._active_generation = generation - runnable = HistoryDetailLoadRunnable(self._signals, generation, entry_id) - QThreadPool.globalInstance().start(runnable) - - def _forward_finished(self, generation: int, payload: object) -> None: + assert self._worker is not None + QMetaObject.invokeMethod( # type: ignore[call-overload] + self._worker, + "load_entry", + Qt.ConnectionType.QueuedConnection, + Q_ARG(int, generation), + Q_ARG(int, entry_id), + ) + + def _on_worker_finished(self, generation: int, payload: object) -> None: if generation != self._active_generation: return + if not isValid(self): + return self.finished.emit(generation, payload) diff --git a/src/ui/sidebar/history/panel.py b/src/ui/sidebar/history/panel.py index 4a13378..5bfc42a 100644 --- a/src/ui/sidebar/history/panel.py +++ b/src/ui/sidebar/history/panel.py @@ -420,7 +420,7 @@ def _apply_items( def closeEvent(self, event: QCloseEvent) -> None: """Cancel in-flight detail/format work before widget teardown.""" - self._detail_loader.cancel() + self._detail_loader.shutdown() self._body_format_runner.cancel() self._req_body_format_runner.cancel() super().closeEvent(event) diff --git a/src/ui/widgets/qt_thread_delivery.py b/src/ui/widgets/qt_thread_delivery.py new file mode 100644 index 0000000..8b77c59 --- /dev/null +++ b/src/ui/widgets/qt_thread_delivery.py @@ -0,0 +1,25 @@ +"""Deliver results from ``QThreadPool`` workers onto the GUI thread.""" + +from __future__ import annotations + +from PySide6.QtCore import QMetaObject, QObject, Qt, Q_ARG +from shiboken6 import isValid + + +def queue_int_str( + target: QObject, + slot: str, + generation: int, + text: str, +) -> bool: + """Invoke *slot* ``(generation, text)`` on the GUI thread; return False if *target* is gone.""" + if not isValid(target): + return False + QMetaObject.invokeMethod( # type: ignore[call-overload] + target, + slot, + Qt.ConnectionType.QueuedConnection, + Q_ARG(int, generation), + Q_ARG(str, text), + ) + return True diff --git a/src/ui/widgets/text_format_async/worker.py b/src/ui/widgets/text_format_async/worker.py index bd019e0..0f8d188 100644 --- a/src/ui/widgets/text_format_async/worker.py +++ b/src/ui/widgets/text_format_async/worker.py @@ -2,10 +2,11 @@ from __future__ import annotations -from PySide6.QtCore import QObject, QRunnable, Signal +from PySide6.QtCore import QObject, QRunnable, Signal, Slot from shiboken6 import isValid from ui.sidebar.saved_responses.helpers import format_code_text +from ui.widgets.qt_thread_delivery import queue_int_str class FormatTextSignals(QObject): @@ -13,6 +14,12 @@ class FormatTextSignals(QObject): finished = Signal(int, str) + @Slot(int, str) + def deliver(self, generation: int, text: str) -> None: + """Emit ``finished`` on the GUI thread (called via :func:`queue_int_str`).""" + if isValid(self): + self.finished.emit(generation, text) + class FormatTextRunnable(QRunnable): """Run :func:`format_code_text` on a thread-pool worker.""" @@ -43,9 +50,7 @@ def run(self) -> None: formatted = format_code_text(self._text, self._language, pretty=self._pretty) except Exception: formatted = self._text - if not isValid(self._signals): - return - self._signals.finished.emit(self._generation, formatted) + queue_int_str(self._signals, "deliver", self._generation, formatted) # Backward-compatible alias for tests. diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index 5249905..1d2e4f9 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -82,6 +82,9 @@ def _reset_popup_and_flush_widgets(qapp: QApplication) -> Iterator[None]: reset_code_editor_popups() dismiss_all_top_level_test_widgets(qapp) + from PySide6.QtCore import QThreadPool + + QThreadPool.globalInstance().waitForDone(5000) flush_deferred_widget_deletes(qapp) From 26fe108e414ecabe771656f91128454782bef9db Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Tue, 2 Jun 2026 20:41:40 +0300 Subject: [PATCH 7/7] feat: Enhance documentation and UI features for request history and duplication - Updated README and documentation to reflect new features including request duplication and comprehensive HTTP send history tracking. - Improved UI components to support the new history and duplication functionalities, ensuring better navigation and user experience. - Added details about the left and right sidebar functionalities, including the integration of history panels for both global and per-request sends. - Clarified settings related to history retention and response body storage in the documentation, enhancing user understanding of the new features. --- README.md | 7 +++++-- docs/README.md | 2 +- docs/getting-started/overview.md | 10 ++++++++++ docs/scripting/security.md | 5 ++++- docs/ui-reference/dialogs.md | 7 ++++--- docs/ui-reference/main-window.md | 16 ++++++++++------ src/services/scripting/_py_sandbox.py | 7 +++---- src/services/scripting/_sandbox_debug.py | 5 +++-- 8 files changed, 40 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d0aba3d..c1e3a7b 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ pm.environment.set("token", pm.response.json()["token"]) ## Features ### Requests & Collections -- Organise requests into nested collections (folders), with drag-and-drop reordering and in-place rename (rollback on failure) +- Organise requests into nested collections (folders), with drag-and-drop reordering, in-place rename (rollback on failure), and **duplicate request** (copies saved responses and assertions) - Import from **Postman collections, cURL commands, or raw URLs** - **GraphQL support** — schema introspection, syntax highlighting, and prettify - Tabbed request editing with breadcrumb navigation and back/forward **tab history** @@ -84,12 +84,15 @@ pm.environment.set("token", pm.response.json()["token"]) - **Encrypted credential storage** for private package registries (OS keychain or encrypted file); secrets are resolved only at run time and never written to plain settings ### History & Versioning +- **HTTP send history** — every Send is recorded (metadata in SQLite, bodies and request snapshots on disk). Browse **per-request** sends on the right History rail or **all workspace** sends on the left; search, date filters, **Replay** (HTTP from snapshot), and **Open** from global history into the correct tab +- **History settings** — retention days, per-day caps, optional response-body storage and size limits (Settings → History) - **Automatic script version history** — snapshots saved as you edit, with a searchable timeline and one-click restore - **Side-by-side diff viewer** — two-column, syntax-highlighted diffs with intra-line change marking, change navigation, and whitespace-aware comparison - **Collection run history** — per-run totals (pass/fail/skip, duration, average response time) with a per-request breakdown ### Workspace & UI -- **VS Code-style left activity rail** with collapsible flyout pages: **Collections & Environments** and **Local scripts & snippets** +- **VS Code-style left activity rail** with collapsible flyout pages: **Collections & Environments**, **Local scripts & snippets**, and **History** (workspace send log) +- **Right icon rail** — Variables, code snippets, saved responses, and per-request **History** (fourth button) - **Bulk key-value editing** — paste many params/headers as one-row-per-line text (`key: value`); prefix a line with `//` to keep but disable it - Resizable key-value columns with inline `{{variable}}` highlighting (distinct colour for unresolved variables) - **Theme support** — automatic OS dark/light detection with manual override — plus Fusion or native widget style and Hi-DPI scaling diff --git a/docs/README.md b/docs/README.md index 67c3a3e..5a86f94 100644 --- a/docs/README.md +++ b/docs/README.md @@ -74,7 +74,7 @@ | [Request Editor](ui-reference/request-editor.md) | Request editing — auth, body search, GraphQL mode | | [Response Viewer](ui-reference/response-viewer.md) | Response display — search, filter, JSONPath/XPath | | [Navigation](ui-reference/navigation.md) | Tab manager, breadcrumb bar, wrapped tab deck | -| [Sidebar](ui-reference/sidebar.md) | Right sidebar — variables, snippets, saved responses | +| [Sidebar](ui-reference/sidebar.md) | Left and right rails — variables, snippets, saved responses, send history (`HistoryPanel`) | | [Dialogs](ui-reference/dialogs.md) | Import, Save, Settings, Collection Runner | | [Panels](ui-reference/panels.md) | Console panel | | [Shared Widgets](ui-reference/widgets.md) | Code editor, key-value table, popups, variable widgets | diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index a634d82..c5c07e6 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -29,6 +29,13 @@ external service required. network metadata popups. - **Saved responses** — save HTTP responses as named examples attached to requests. +- **HTTP send history** — automatic log of every Send with search and + calendar date filters. **Right sidebar → History** for the active saved + request (detail, replay from snapshot). **Left sidebar → History** for + all workspace sends (open into the matching request tab or a draft for + deleted/orphan rows). +- **Request duplication** — duplicate a saved request from the collection + tree (includes saved responses and assertions). - **Code snippets** — generate request code in 23 languages (cURL, Python, JavaScript, Go, Rust, Java, etc.). - **Import** — import from Postman collections/environments, cURL commands, @@ -54,6 +61,9 @@ external service required. bracket matching, search and replace. - **SQLite persistence** — all data stored locally with WAL journal mode for concurrent reads. +- **Send history storage** — history metadata in the app database; response + bodies and request snapshots under the user data directory (configurable + in Settings → History). ## Technology Stack diff --git a/docs/scripting/security.md b/docs/scripting/security.md index 19f8849..e43da4c 100644 --- a/docs/scripting/security.md +++ b/docs/scripting/security.md @@ -109,7 +109,10 @@ removed: | Memory (address space) | 128 MB | `RLIMIT_AS` | | File descriptors | 3 (stdin/stdout/stderr only) | `RLIMIT_NOFILE` | -Implementation: `_py_sandbox.py::_apply_resource_limits()`. On +Implementation: `_sandbox_runtime._apply_resource_limits()`, called +immediately before ``exec()`` in the sandbox worker (after compile and +namespace setup). Applying ``RLIMIT_NOFILE`` earlier would break +``compile_restricted`` and module imports under parallel test load. On non-Linux systems, limits are best-effort (may not apply). ### Attribute Guard diff --git a/docs/ui-reference/dialogs.md b/docs/ui-reference/dialogs.md index f6997b3..a6e7543 100644 --- a/docs/ui-reference/dialogs.md +++ b/docs/ui-reference/dialogs.md @@ -49,9 +49,9 @@ Save a draft request to an existing or new collection. ## SettingsDialog Application preferences dialog. Optional constructor keyword -`initial_category` (``"Appearance"``, ``"Tabs"``, or ``"Scripting"``) -selects the list row on open; the **Scripting** page holds the Deno path -and managed download. +`initial_category` (``"Appearance"``, ``"Tabs"``, ``"Scripting"``, or +``"History"``) selects the list row on open; the **Scripting** page holds +the Deno path and managed download. ### Category Pages @@ -60,6 +60,7 @@ and managed download. | Appearance | Style (Fusion / Native), colour scheme (Auto / Light / Dark) | | Tabs | Tab limit, close policies, activate-on-close, wrap mode | | Scripting | Deno executable path, validation, managed download; Python path; LSP toggle (tooltip notes debounced `didChange` / `pm.require` indexing); **Reset LSP workspace caches** (`pm_require_types.reset_workspace`); auto-save default; **Private package registries** (npm / JSR scope-mapped + default-npm override + PyPI primary/extra index with embedded auth) | +| History | Send retention (days), max entries per day (or unlimited), save response bodies toggle, max response size (MiB), read-only storage path under user data (`history/`). Applied on next send and by background prune. See [RequestHistoryService](../api-reference/services/request-history-service.md). | ### Private package registries (Scripting page) diff --git a/docs/ui-reference/main-window.md b/docs/ui-reference/main-window.md index de76186..74dd33c 100644 --- a/docs/ui-reference/main-window.md +++ b/docs/ui-reference/main-window.md @@ -38,10 +38,12 @@ QMainWindow +------------------------------------------------------------------------+ ``` -The narrow **left rail** (`LeftSidebar`, Phosphor **files** and **code** icons) -mirrors the right rail: it toggles a collapsible flyout with a ``QStackedWidget`` -whose default page is ``_left_nav_splitter`` (collections above environments). -The **code** icon switches to ``LocalScriptsSidebarPanel`` (placeholder). **View → Toggle Sidebar** (``Ctrl+B``) +The narrow **left rail** (`LeftSidebar`, Phosphor **files**, **code**, and +**clock-counter-clockwise** icons) mirrors the right rail: it toggles a +collapsible flyout with a ``QStackedWidget``. Page 0 is ``_left_nav_splitter`` +(collections above environments). The **code** icon opens local scripts and +snippets. The **History** icon opens ``HistoryPanel`` in global mode (workspace +send log). **View → Toggle Sidebar** (``Ctrl+B``) collapses or expands that flyout to the same widths as dragging the splitter handle; the rail stays visible. The main central ``QHBoxLayout`` has **no** outer margins so the left rail is flush with the window edge and its @@ -56,8 +58,10 @@ strip. | `_editor_stack` | `QStackedWidget` | Per-tab request editor stack | | `_response_stack` | `QStackedWidget` | Per-tab response viewer stack | | `_breadcrumb_bar` | `BreadcrumbBar` | Path navigation bar | -| `_left_sidebar` | `LeftSidebar` | Left activity rail + stacked flyout (collections / environments vs local scripts) | -| `_right_sidebar` | `RightSidebar` | Right icon rail and flyout panel | +| `_left_sidebar` | `LeftSidebar` | Left activity rail + stacked flyout (collections / environments, local scripts & snippets, global history) | +| `_right_sidebar` | `RightSidebar` | Right icon rail and flyout panel (variables, snippets, saved responses, per-request history) | +| `_global_history_panel` | `HistoryPanel` | Left-rail workspace send history (`globalHistoryPanel`) | +| `_request_history_panel` | `HistoryPanel` | Right-rail per-request send history (`requestHistoryPanel`) | | `collection_widget` | `CollectionWidget` | Collection tree + header (top of left column) | | `_left_nav_splitter` | `QSplitter` | Vertical splitter inside the left flyout: collections above environments | | `_local_scripts_sidebar` | `LocalScriptsSidebarPanel` | Local scripts flyout page (placeholder list shell) | diff --git a/src/services/scripting/_py_sandbox.py b/src/services/scripting/_py_sandbox.py index cc5266c..e97adcf 100644 --- a/src/services/scripting/_py_sandbox.py +++ b/src/services/scripting/_py_sandbox.py @@ -44,6 +44,7 @@ _write_done, ) from services.scripting._sandbox_safe_globals import _SAFE_BUILTINS, _SAFE_STDLIB +from services.scripting.context import harvest_legacy_tests __all__ = [ "_HeaderList", @@ -59,7 +60,6 @@ def main() -> None: """Read ScriptInput from stdin, execute script, write ScriptOutput to stdout.""" - _apply_resource_limits() raw = sys.stdin.readline() if not raw or not raw.strip(): _write_done(_error_output("No input received")) @@ -120,7 +120,8 @@ def _execute_restricted(script: str, pm: _Pm) -> dict[str, Any]: # object whose ``_call_print`` forwards to our console capture. restricted_globals["_print_"] = _ConsolePrintCollector - # 3. Execute. + # 3. Execute (resource limits after compile/setup — RLIMIT_NOFILE=3 breaks imports). + _apply_resource_limits() try: exec(code, restricted_globals) except Exception as e: @@ -129,8 +130,6 @@ def _execute_restricted(script: str, pm: _Pm) -> dict[str, Any]: {"name": "(runtime error)", "passed": False, "error": str(e), "duration_ms": 0.0} ) - from services.scripting.context import harvest_legacy_tests - harvest_legacy_tests(restricted_globals.get("tests"), pm._test_results) # 4. Build output. diff --git a/src/services/scripting/_sandbox_debug.py b/src/services/scripting/_sandbox_debug.py index c8dae68..b988a3f 100644 --- a/src/services/scripting/_sandbox_debug.py +++ b/src/services/scripting/_sandbox_debug.py @@ -9,11 +9,13 @@ from services.scripting._sandbox_pm import _Pm, _serialize_request_mutations from services.scripting._sandbox_runtime import ( _ConsolePrintCollector, + _apply_resource_limits, _console_emit, _console_logs, _error_output, _getattr_guard, ) +from services.scripting.context import harvest_legacy_tests from services.scripting._sandbox_safe_globals import _SAFE_BUILTINS, _SAFE_STDLIB try: @@ -352,6 +354,7 @@ def _trace_fn(frame: Any, event: str, arg: Any) -> Any: initial_namespace.update(restricted_globals.keys()) sys.settrace(_trace_fn) + _apply_resource_limits() try: exec(code, restricted_globals) except SystemExit: @@ -364,8 +367,6 @@ def _trace_fn(frame: Any, event: str, arg: Any) -> Any: finally: sys.settrace(None) - from services.scripting.context import harvest_legacy_tests - harvest_legacy_tests(restricted_globals.get("tests"), pm._test_results) all_changes: dict[str, str] = {}