From d52f20ddd8ce42cfc14118319f1dc783214a4049 Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Sat, 14 Mar 2026 20:50:35 +0200 Subject: [PATCH 1/6] update --- .github/copilot-instructions.md | 13 +- .../instructions/architecture.instructions.md | 46 +- .github/instructions/testing.instructions.md | 23 +- src/main.py | 7 +- src/ui/collections/tree/collection_tree.py | 17 +- src/ui/dialogs/settings_dialog.py | 154 ++++- src/ui/main_window/draft_controller.py | 29 +- src/ui/main_window/tab_controller.py | 232 ++++++- src/ui/main_window/window.py | 90 ++- src/ui/request/navigation/__init__.py | 4 + src/ui/request/navigation/request_tab_bar.py | 359 +--------- .../navigation/request_tabs/__init__.py | 7 + src/ui/request/navigation/request_tabs/bar.py | 613 ++++++++++++++++++ .../request/navigation/request_tabs/labels.py | 278 ++++++++ .../navigation/request_tabs/tab_button.py | 203 ++++++ src/ui/request/navigation/tab_manager.py | 8 + src/ui/styling/tab_settings_manager.py | 228 +++++++ tests/conftest.py | 8 + .../test_collection_tree_actions.py | 28 +- tests/ui/dialogs/test_settings_dialog.py | 56 ++ .../navigation/test_request_tab_bar.py | 113 ++++ tests/ui/test_main_window.py | 103 +++ tests/ui/test_main_window_tabs_navigation.py | 56 ++ 23 files changed, 2255 insertions(+), 420 deletions(-) create mode 100644 src/ui/request/navigation/request_tabs/__init__.py create mode 100644 src/ui/request/navigation/request_tabs/bar.py create mode 100644 src/ui/request/navigation/request_tabs/labels.py create mode 100644 src/ui/request/navigation/request_tabs/tab_button.py create mode 100644 src/ui/styling/tab_settings_manager.py create mode 100644 tests/ui/test_main_window_tabs_navigation.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fac71b0..7545e3e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -194,6 +194,7 @@ src/ ├── styling/ # Visual theming and icons │ ├── theme.py # Palettes, colours, badge geometry, method_color(), status_color() │ ├── theme_manager.py # ThemeManager — QPalette + QSettings + │ ├── tab_settings_manager.py # TabSettingsManager — request-tab QSettings bridge (preview, limits, activate-on-close, wrap mode) │ ├── global_qss.py # build_global_qss() — global stylesheet builder │ └── icons.py # Phosphor font-glyph icon provider (phi()) ├── widgets/ # Reusable shared components @@ -221,7 +222,7 @@ src/ │ ├── collection_runner.py │ ├── import_dialog.py │ ├── save_request_dialog.py # Save draft request to collection - │ └── settings_dialog.py # Settings (theme, colour scheme) + │ └── settings_dialog.py # Settings (theme + request-tab behaviour) ├── environments/ # Environment management widgets │ ├── environment_editor.py │ └── environment_selector.py @@ -247,7 +248,12 @@ src/ │ └── search_filter.py # _SearchFilterMixin — response search/filter ├── navigation/ # Tab switching and path navigation │ ├── breadcrumb_bar.py - │ ├── request_tab_bar.py + │ ├── request_tab_bar.py # Compatibility wrapper re-exporting the wrapped deck + │ ├── request_tabs/ # Wrapped multi-row request tab deck sub-package + │ │ ├── __init__.py + │ │ ├── bar.py # RequestTabBar custom wrapped-row deck + │ │ ├── labels.py # TabLabel / FolderTabLabel chip content widgets + │ │ └── tab_button.py # TabButton chip with close + reorder interactions │ └── tab_manager.py # TabManager + TabContext (with local_overrides, draft_name) └── popups/ # Response metadata popups ├── status_popup.py # HTTP status code explanation @@ -255,7 +261,7 @@ src/ ├── size_popup.py # Response/request size breakdown └── network_popup.py # Network/TLS connection details tests/ -├── conftest.py # Autouse fresh-DB fixture + qapp fixture +├── conftest.py # Autouse fresh-DB fixture + qapp fixture + tab-settings reset ├── unit/ # Repository & service layer tests │ ├── database/ # Repository tests │ │ ├── test_repository.py @@ -277,6 +283,7 @@ tests/ └── ui/ # End-to-end PySide6 widget tests ├── conftest.py # _no_fetch (autouse) + helpers ├── test_main_window.py + ├── test_main_window_tabs_navigation.py # Wrapped tab deck shortcuts + search tests ├── test_main_window_save.py # SaveButton + RequestSaveEndToEnd tests ├── test_main_window_draft.py # Draft tab open/save lifecycle tests ├── styling/ # Theme and icon tests diff --git a/.github/instructions/architecture.instructions.md b/.github/instructions/architecture.instructions.md index 0d23b4d..f62238d 100644 --- a/.github/instructions/architecture.instructions.md +++ b/.github/instructions/architecture.instructions.md @@ -54,6 +54,9 @@ ThemeManager ──QPalette + global QSS──► QApplication ──theme_changed signal──► widgets (refresh dynamic styles) ──QSettings──► persistent user preferences +TabSettingsManager ──QSettings──► persistent request-tab preferences + ──settings_changed──► MainWindow / RequestTabBar + RequestEditorWidget ──send_requested──► MainWindow MainWindow → HttpSendWorker (QThread) → HttpService.send_request() → HttpSendWorker.finished(HttpResponseDict) → ResponseViewerWidget.load_response() @@ -68,6 +71,11 @@ RequestEditorWidget ──_on_fetch_schema──► SchemaFetchWorker (QThread and passed to `MainWindow`. It owns the app-wide stylesheet, QPalette, and QSettings persistence for theme preferences. See `pyside6.instructions.md` for widget styling rules. +- `TabSettingsManager` (`ui.styling.tab_settings_manager`) is created once + in `main.py` and passed to `MainWindow`. It persists request-tab + behaviour (preview enablement, compact labels, duplicate-name path + disambiguation, wrap mode, tab limit, and close-activation policy) + via QSettings. - `CollectionService` is instantiated as `self._svc = CollectionService()` in `CollectionWidget.__init__`, but **every method is `@staticmethod`**. Do not add instance state without updating every call site. @@ -144,7 +152,15 @@ Key signals to know (always-on summary): - `SavedResponsesPanel` emits `save_current_requested`, `rename_requested`, `duplicate_requested`, and `delete_requested` — all handled in `MainWindow` through `CollectionService`. -- `ThemeManager.theme_changed()` → widgets refresh dynamic styles. +- `ThemeManager.theme_changed()` → widgets refresh dynamic styles, including + the wrapped request-tab deck chip styling. +- `TabSettingsManager.settings_changed()` → `MainWindow` / `RequestTabBar` + refresh tab behaviour and label presentation, including switching + between single-row and wrapped-row layouts. +- `MainWindow` View menu exposes `Search Tabs…` (`Ctrl+P`), `Next Tab` + (`Ctrl+Tab`, `Ctrl+PgDown`), and `Previous Tab` + (`Ctrl+Shift+Tab`, `Ctrl+PgUp`) so the wrapped deck keeps editor-style + keyboard navigation even though it is no longer a native `QTabBar`. - `VariablePopup` uses **class-level callbacks**, not signals — wired once in `MainWindow.__init__`. @@ -281,15 +297,31 @@ into `%Y-%m-%d %H:%M` strings for the UI. 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 save-to-collection dialog. `None` for persisted request tabs. -8. **VariablePopup uses class-level callbacks, not Qt signals** — +8. **Request-tab behaviour is settings-driven** — preview tabs, compact + labels, duplicate-name path suffixes, tab insertion position, wrap + mode, tab limit, and close-activation policy are read from + `TabSettingsManager`. + `RequestTabBar` is a custom wrapped multi-row widget, not a native + `QTabBar`; it keeps a small compatibility API (`currentIndex()`, + `setCurrentIndex()`, `count()`, `tabRect()`, `tabButton()`, + `tabToolTip()`, `select_next_tab()`, `select_previous_tab()`, + `tab_search_text()`) so `MainWindow` and tests do not depend on Qt + tab-bar internals. `MainWindow` enforces the limit/promotion policies + when opening and closing tabs. +9. **Manual tab reorder changes close-unchanged priority** — when the user + drags tabs into a new visible order, `_TabControllerMixin._on_tab_reordered` + rewrites `TabContext.opened_order` to match that order. The + `close_unchanged` limit policy then evicts the leftmost eligible, + unchanged tab instead of an older pre-drag ordering. +10. **VariablePopup uses class-level callbacks, not Qt signals** — `VariablePopup` is a **singleton** `QFrame`. Its callbacks (`set_save_callback`, `set_local_override_callback`, `set_reset_local_override_callback`, `set_add_variable_callback`, `set_has_environment`) are classmethods that store callables on the **class itself**, not on an instance. They are wired once in `MainWindow.__init__` and survive popup hide/show cycles. - 9. **Saved response mutations are MainWindow-owned** — - `SavedResponsesPanel` is a read-only/browser widget. It never imports the - repository or service directly for mutations; it only emits signals to - `MainWindow`, which calls `CollectionService` and then refreshes the - sidebar state. +11. **Saved response mutations are MainWindow-owned** — + `SavedResponsesPanel` is a read-only/browser widget. It never imports the + repository or service directly for mutations; it only emits signals to + `MainWindow`, which calls `CollectionService` and then refreshes the + sidebar state. diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index ec92874..105086c 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -15,14 +15,17 @@ applyTo: "tests/**/*.py" full validation checklist. 3. **Each test gets a fresh SQLite database** — the `_fresh_db` autouse fixture handles this. Never share DB state between tests. -4. **UI tests need `qapp` and `qtbot` fixtures.** Register widgets with +4. **Each test starts with cleared tab preferences** — the + `_reset_tab_settings` autouse fixture removes the `tabs/*` QSettings + group so preview/tab-limit settings never leak between cases. +5. **UI tests need `qapp` and `qtbot` fixtures.** Register widgets with `qtbot.addWidget(widget)`. -5. **The `_no_fetch` fixture is autouse in `tests/ui/`** — it prevents +6. **The `_no_fetch` fixture is autouse in `tests/ui/`** — it prevents `CollectionWidget` from spawning a background thread. You do not need to apply it manually. -6. **Use bare module imports** (e.g. `from database.database import init_db`) +7. **Use bare module imports** (e.g. `from database.database import init_db`) — `src/` is on the Python path. -7. **Do not test the session or engine directly** — test through the +8. **Do not test the session or engine directly** — test through the repository or service layer. ## Fresh database per test (autouse fixture) @@ -47,6 +50,13 @@ init_db(tmp_path / "test.db") single `QApplication` instance. All UI tests must accept `qapp` and use `qtbot.addWidget(widget)` for cleanup. +## Fresh QSettings tab preferences per test (autouse fixture) + +`conftest.py` also provides `_reset_tab_settings`, which removes the +`tabs` QSettings group before every test. Use this when adding persisted +request-tab settings so one test cannot silently change preview or tab-limit +behaviour for the next. + ## `_no_fetch` fixture — avoiding background threads in tests `CollectionWidget.__init__` spawns a `QThread` that queries the database. @@ -102,7 +112,7 @@ test file still exceeds 600 lines, split by test class into separate files. ``` tests/ -├── conftest.py # Root: _fresh_db (autouse) + qapp (session) +├── conftest.py # Root: _fresh_db + _reset_tab_settings (autouse) + qapp ├── unit/ # Pure logic — no Qt widgets │ ├── database/ # Repository layer tests │ │ ├── test_repository.py @@ -123,7 +133,8 @@ tests/ │ └── test_oauth2_service.py └── ui/ # PySide6 widget tests (need qapp + qtbot) ├── conftest.py # _no_fetch (autouse) + helper functions - ├── test_main_window.py # Top-level MainWindow smoke tests + ├── test_main_window.py # Top-level MainWindow smoke tests + ├── test_main_window_tabs_navigation.py # Wrapped tab deck shortcuts + search tests ├── test_main_window_save.py # SaveButton + RequestSaveEndToEnd tests ├── test_main_window_draft.py # Draft tab open/save lifecycle tests ├── styling/ # Theme and icon tests diff --git a/src/main.py b/src/main.py index d9c693d..28ac2a1 100644 --- a/src/main.py +++ b/src/main.py @@ -10,6 +10,7 @@ from database.database import init_db from ui.main_window import MainWindow from ui.styling.icons import load_font +from ui.styling.tab_settings_manager import TabSettingsManager from ui.styling.theme_manager import ThemeManager # -------------------------------------------------------------------------- @@ -20,6 +21,7 @@ # Apply theme (reads QSettings, sets style + palette + global QSS) theme_manager = ThemeManager(app) + tab_settings_manager = TabSettingsManager(app) # Load the Phosphor icon font (must happen after QApplication) load_font() @@ -27,6 +29,9 @@ # Initialise the database before any widget accesses it init_db() - window = MainWindow(theme_manager=theme_manager) + window = MainWindow( + theme_manager=theme_manager, + tab_settings_manager=tab_settings_manager, + ) window.show() sys.exit(app.exec()) diff --git a/src/ui/collections/tree/collection_tree.py b/src/ui/collections/tree/collection_tree.py index c737628..493b250 100644 --- a/src/ui/collections/tree/collection_tree.py +++ b/src/ui/collections/tree/collection_tree.py @@ -100,6 +100,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self._tree.setDragDropMode(QTreeWidget.DragDropMode.InternalMove) self._tree.viewport().setAcceptDrops(True) + # Hand cursor over tree items + self._tree.setCursor(Qt.CursorShape.PointingHandCursor) + # Column 1 holds metadata (name, type) via data roles — hide visually self._tree.hideColumn(1) @@ -177,19 +180,17 @@ def _on_item_collapsed(self, item: QTreeWidgetItem) -> None: item.setIcon(0, phi("folder")) def _on_item_clicked(self, item: QTreeWidgetItem, column: int) -> None: - """Emit a ``Preview`` action when a request item is clicked.""" + """Open a request tab or toggle folder expand on single click.""" item_type = item.data(1, ROLE_ITEM_TYPE) if item_type == "request": item_id = item.data(0, ROLE_ITEM_ID) - self.item_action_triggered.emit(item_type, item_id, "Preview") + self.item_action_triggered.emit(item_type, item_id, "Open") + elif item_type == "folder": + item.setExpanded(not item.isExpanded()) def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None: - """Emit an ``Open`` action when a request item is double-clicked.""" - item_type = item.data(1, ROLE_ITEM_TYPE) - if item_type != "request": - return - item_id = item.data(0, ROLE_ITEM_ID) - self.item_action_triggered.emit(item_type, item_id, "Open") + """No-op — single click already opens items.""" + return def _count_real_children(self, item: QTreeWidgetItem) -> int: """Count children excluding placeholder sentinel items.""" diff --git a/src/ui/dialogs/settings_dialog.py b/src/ui/dialogs/settings_dialog.py index 75a3dda..be54bfb 100644 --- a/src/ui/dialogs/settings_dialog.py +++ b/src/ui/dialogs/settings_dialog.py @@ -9,12 +9,14 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDialog, QHBoxLayout, QLabel, QListWidget, QPushButton, + QSpinBox, QSplitter, QStackedWidget, QVBoxLayout, @@ -22,6 +24,18 @@ ) from ui.styling.icons import phi +from ui.styling.tab_settings_manager import ( + ACTIVATE_LEFT, + ACTIVATE_MRU, + ACTIVATE_RIGHT, + LIMIT_CLOSE_UNCHANGED, + LIMIT_CLOSE_UNUSED, + MAX_TAB_LIMIT, + MIN_TAB_LIMIT, + WRAP_MULTIPLE_ROWS, + WRAP_SINGLE_ROW, + TabSettingsManager, +) from ui.styling.theme_manager import ( SCHEME_AUTO, SCHEME_DARK, @@ -38,7 +52,8 @@ class SettingsDialog(QDialog): def __init__( self, - theme_manager: ThemeManager, + theme_manager: ThemeManager | None, + tab_settings_manager: TabSettingsManager | None = None, parent: QWidget | None = None, ) -> None: """Initialise the settings dialog.""" @@ -49,6 +64,7 @@ def __init__( self.setModal(True) self._tm = theme_manager + self._tab_settings = tab_settings_manager or TabSettingsManager(self) root = QVBoxLayout(self) @@ -59,6 +75,7 @@ def __init__( # Category list self._cat_list = QListWidget() self._cat_list.addItem("Appearance") + self._cat_list.addItem("Tabs") self._cat_list.setFixedWidth(140) self._cat_list.setCurrentRow(0) self._cat_list.currentRowChanged.connect(self._on_category_changed) @@ -71,6 +88,7 @@ def __init__( # -- Appearance page ------------------------------------------- self._build_appearance_page() + self._build_tabs_page() # -- Button row ------------------------------------------------ btn_row = QHBoxLayout() @@ -115,8 +133,10 @@ def _build_appearance_page(self) -> None: display = f"{s} (recommended)" if s == STYLE_FUSION else s self._style_combo.addItem(display, userData=s) # Set current value - idx = list(STYLES).index(self._tm.style) if self._tm.style in STYLES else 0 + current_style = self._tm.style if self._tm is not None else STYLE_FUSION + idx = list(STYLES).index(current_style) if current_style in STYLES else 0 self._style_combo.setCurrentIndex(idx) + self._style_combo.setEnabled(self._tm is not None) layout.addWidget(self._style_combo) # Colour scheme selector @@ -128,13 +148,112 @@ def _build_appearance_page(self) -> None: scheme_labels = {SCHEME_AUTO: "Auto-detect", SCHEME_LIGHT: "Light", SCHEME_DARK: "Dark"} for s in SCHEMES: self._scheme_combo.addItem(scheme_labels.get(s, s), userData=s) - idx = list(SCHEMES).index(self._tm.scheme) if self._tm.scheme in SCHEMES else 0 + current_scheme = self._tm.scheme if self._tm is not None else SCHEME_AUTO + idx = list(SCHEMES).index(current_scheme) if current_scheme in SCHEMES else 0 self._scheme_combo.setCurrentIndex(idx) + self._scheme_combo.setEnabled(self._tm is not None) layout.addWidget(self._scheme_combo) layout.addStretch() self._stack.addWidget(page) + def _build_tabs_page(self) -> None: + """Build the request-tab settings page.""" + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(12) + + heading = QLabel("Tabs") + heading.setObjectName("titleLabel") + layout.addWidget(heading) + + appearance_label = QLabel("Appearance") + appearance_label.setObjectName("sectionLabel") + layout.addWidget(appearance_label) + + wrap_mode_row = QHBoxLayout() + wrap_mode_row.setContentsMargins(0, 0, 0, 0) + wrap_mode_row.addWidget(QLabel("Tab rows")) + self._wrap_mode_combo = QComboBox() + self._wrap_mode_combo.addItem("Wrap onto multiple rows", userData=WRAP_MULTIPLE_ROWS) + self._wrap_mode_combo.addItem("Keep a single row", userData=WRAP_SINGLE_ROW) + for idx in range(self._wrap_mode_combo.count()): + if self._wrap_mode_combo.itemData(idx) == self._tab_settings.wrap_mode: + self._wrap_mode_combo.setCurrentIndex(idx) + break + wrap_mode_row.addWidget(self._wrap_mode_combo) + wrap_mode_row.addStretch() + layout.addLayout(wrap_mode_row) + + self._small_labels_check = QCheckBox("Use small font for labels") + self._small_labels_check.setChecked(self._tab_settings.small_labels) + layout.addWidget(self._small_labels_check) + + self._show_path_duplicates_check = QCheckBox("Show path for non-unique request names") + self._show_path_duplicates_check.setChecked(self._tab_settings.show_path_for_duplicates) + layout.addWidget(self._show_path_duplicates_check) + + self._mark_modified_check = QCheckBox("Mark modified requests") + self._mark_modified_check.setChecked(self._tab_settings.mark_modified) + layout.addWidget(self._mark_modified_check) + + self._show_full_path_hover_check = QCheckBox("Show full request path on hover") + self._show_full_path_hover_check.setChecked(self._tab_settings.show_full_path_on_hover) + layout.addWidget(self._show_full_path_hover_check) + + order_label = QLabel("Tab Order") + order_label.setObjectName("sectionLabel") + layout.addWidget(order_label) + + self._open_new_tabs_at_end_check = QCheckBox("Open new tabs at the end") + self._open_new_tabs_at_end_check.setChecked(self._tab_settings.open_new_tabs_at_end) + layout.addWidget(self._open_new_tabs_at_end_check) + + opening_label = QLabel("Opening Policy") + opening_label.setObjectName("sectionLabel") + layout.addWidget(opening_label) + + self._preview_tab_check = QCheckBox("Enable preview tab") + self._preview_tab_check.setChecked(self._tab_settings.enable_preview_tab) + layout.addWidget(self._preview_tab_check) + + closing_label = QLabel("Closing Policy") + closing_label.setObjectName("sectionLabel") + layout.addWidget(closing_label) + + limit_row = QHBoxLayout() + limit_row.setContentsMargins(0, 0, 0, 0) + limit_row.addWidget(QLabel("Tab limit")) + self._tab_limit_spin = QSpinBox() + self._tab_limit_spin.setRange(MIN_TAB_LIMIT, MAX_TAB_LIMIT) + self._tab_limit_spin.setValue(self._tab_settings.tab_limit) + limit_row.addWidget(self._tab_limit_spin) + limit_row.addStretch() + layout.addLayout(limit_row) + + self._tab_limit_policy_combo = QComboBox() + self._tab_limit_policy_combo.addItem("Close unchanged", userData=LIMIT_CLOSE_UNCHANGED) + self._tab_limit_policy_combo.addItem("Close unused", userData=LIMIT_CLOSE_UNUSED) + for idx in range(self._tab_limit_policy_combo.count()): + if self._tab_limit_policy_combo.itemData(idx) == self._tab_settings.tab_limit_policy: + self._tab_limit_policy_combo.setCurrentIndex(idx) + break + layout.addWidget(self._tab_limit_policy_combo) + + self._activate_on_close_combo = QComboBox() + self._activate_on_close_combo.addItem("Tab on the left", userData=ACTIVATE_LEFT) + self._activate_on_close_combo.addItem("Tab on the right", userData=ACTIVATE_RIGHT) + self._activate_on_close_combo.addItem("Most recently used tab", userData=ACTIVATE_MRU) + for idx in range(self._activate_on_close_combo.count()): + if self._activate_on_close_combo.itemData(idx) == self._tab_settings.activate_on_close: + self._activate_on_close_combo.setCurrentIndex(idx) + break + layout.addWidget(self._activate_on_close_combo) + + layout.addStretch() + self._stack.addWidget(page) + # -- Slots --------------------------------------------------------- def _on_category_changed(self, row: int) -> None: @@ -146,6 +265,29 @@ def _on_apply(self) -> None: style_data = self._style_combo.currentData() scheme_data = self._scheme_combo.currentData() - self._tm.style = style_data if isinstance(style_data, str) else STYLE_FUSION - self._tm.scheme = scheme_data if isinstance(scheme_data, str) else SCHEME_AUTO - self._tm.apply() + if self._tm is not None: + self._tm.style = style_data if isinstance(style_data, str) else STYLE_FUSION + self._tm.scheme = scheme_data if isinstance(scheme_data, str) else SCHEME_AUTO + self._tm.apply() + + wrap_mode = self._wrap_mode_combo.currentData() + self._tab_settings.wrap_mode = ( + wrap_mode if isinstance(wrap_mode, str) else WRAP_MULTIPLE_ROWS + ) + self._tab_settings.small_labels = self._small_labels_check.isChecked() + self._tab_settings.show_path_for_duplicates = self._show_path_duplicates_check.isChecked() + self._tab_settings.mark_modified = self._mark_modified_check.isChecked() + self._tab_settings.show_full_path_on_hover = self._show_full_path_hover_check.isChecked() + self._tab_settings.open_new_tabs_at_end = self._open_new_tabs_at_end_check.isChecked() + self._tab_settings.enable_preview_tab = self._preview_tab_check.isChecked() + self._tab_settings.tab_limit = self._tab_limit_spin.value() + + tab_limit_policy = self._tab_limit_policy_combo.currentData() + self._tab_settings.tab_limit_policy = ( + tab_limit_policy if isinstance(tab_limit_policy, str) else LIMIT_CLOSE_UNUSED + ) + + activate_on_close = self._activate_on_close_combo.currentData() + self._tab_settings.activate_on_close = ( + activate_on_close if isinstance(activate_on_close, str) else ACTIVATE_MRU + ) diff --git a/src/ui/main_window/draft_controller.py b/src/ui/main_window/draft_controller.py index 92fd5a5..15a3ccd 100644 --- a/src/ui/main_window/draft_controller.py +++ b/src/ui/main_window/draft_controller.py @@ -52,7 +52,13 @@ def _on_send_request(self) -> None: ... def _on_save_request(self) -> None: ... def _on_save_response(self, data: dict) -> 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: ... + def _enforce_tab_limit_before_open(self) -> bool: ... + def _next_tab_open_order(self) -> int: ... + def _next_tab_insert_index(self) -> int: ... + def _shift_tabs_for_insert(self, index: int) -> None: ... + def _request_full_path(self, request_id: int) -> str | None: ... # ------------------------------------------------------------------ # Open a new draft request tab @@ -64,6 +70,9 @@ def _open_draft_request(self) -> None: the Save button is enabled. Saving triggers the save-to-collection dialog. """ + if not self._enforce_tab_limit_before_open(): + return + data: RequestLoadDict = { "name": _DRAFT_TAB_NAME, "method": "GET", @@ -80,11 +89,20 @@ def _open_draft_request(self) -> None: request_id=None, editor=editor, response_viewer=viewer, + opened_order=self._next_tab_open_order(), ) + insert_index = self._next_tab_insert_index() + self._shift_tabs_for_insert(insert_index) + self._tab_bar.blockSignals(True) try: - idx = self._tab_bar.add_request_tab("GET", _DRAFT_TAB_NAME) + idx = self._tab_bar.add_request_tab( + "GET", + _DRAFT_TAB_NAME, + path=_DRAFT_TAB_NAME, + index=insert_index, + ) finally: self._tab_bar.blockSignals(False) @@ -95,6 +113,7 @@ def _open_draft_request(self) -> None: editor.send_requested.connect(self._on_send_request) 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) viewer.save_response_requested.connect(self._on_save_response) # Mark as dirty so Save button is enabled for the new draft @@ -156,7 +175,13 @@ def _save_draft_request(self, ctx: TabContext | None, editor: RequestEditorWidge ctx.request_id = new_request.id idx = self._tab_bar.currentIndex() display_name = url if url else request_name - self._tab_bar.update_tab(idx, method=method, name=display_name, is_dirty=False) + self._tab_bar.update_tab( + idx, + method=method, + name=display_name, + path=self._request_full_path(new_request.id), + is_dirty=False, + ) # Refresh breadcrumb crumbs = CollectionService.get_request_breadcrumb(new_request.id) self._breadcrumb_bar.set_path(crumbs) diff --git a/src/ui/main_window/tab_controller.py b/src/ui/main_window/tab_controller.py index 57d081e..3153cca 100644 --- a/src/ui/main_window/tab_controller.py +++ b/src/ui/main_window/tab_controller.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast from services.collection_service import CollectionService, RequestLoadDict from ui.request.navigation.tab_manager import TabContext @@ -21,6 +21,7 @@ from ui.collections.collection_widget import CollectionWidget from ui.request.navigation.breadcrumb_bar import BreadcrumbBar from ui.request.navigation.request_tab_bar import RequestTabBar + from ui.styling.tab_settings_manager import TabSettingsManager logger = logging.getLogger(__name__) @@ -56,6 +57,9 @@ class _TabControllerMixin: collection_widget: CollectionWidget back_action: QAction forward_action: QAction + _tab_settings_manager: TabSettingsManager + _tab_open_counter: int + _tab_activation_counter: int def _on_send_request(self) -> None: ... def _on_save_request(self) -> None: ... @@ -87,11 +91,16 @@ def _open_request( replaced by subsequent preview opens. When ``False`` (the default) the tab is permanent. """ + if is_preview and not self._tab_settings_manager.enable_preview_tab: + is_preview = False + request = CollectionService.get_request(request_id) if request is None: logger.warning("Request id=%s not found", request_id) return + request_path = self._request_full_path(request_id) + data: RequestLoadDict = { "name": request.name, "method": request.method, @@ -120,10 +129,16 @@ def _open_request( current_idx = self._tab_bar.currentIndex() current_ctx = self._tabs.get(current_idx) if current_ctx is not None and current_ctx.is_preview: - self._replace_tab(current_idx, request_id, data, is_preview=is_preview) + self._replace_tab( + current_idx, + request_id, + data, + is_preview=is_preview, + path=request_path, + ) else: # 3. Open a new tab - self._create_tab(request_id, data, is_preview=is_preview) + self._create_tab(request_id, data, is_preview=is_preview, path=request_path) if push_history: self._history = self._history[: self._history_index + 1] @@ -142,8 +157,12 @@ def _create_tab( data: RequestLoadDict, *, is_preview: bool = False, + path: str | None = None, ) -> int: """Create a new tab for a request and switch to it.""" + if not self._enforce_tab_limit_before_open(): + return self._tab_bar.currentIndex() + editor = RequestEditorWidget() viewer = ResponseViewerWidget() @@ -155,8 +174,12 @@ def _create_tab( editor=editor, response_viewer=viewer, is_preview=is_preview, + opened_order=self._next_tab_open_order(), ) + insert_index = self._next_tab_insert_index() + self._shift_tabs_for_insert(insert_index) + # Block signals while adding the tab to avoid premature # _on_tab_changed before ctx is stored. self._tab_bar.blockSignals(True) @@ -165,6 +188,8 @@ def _create_tab( data.get("method", "GET"), data.get("name", ""), is_preview=is_preview, + path=path, + index=insert_index, ) finally: self._tab_bar.blockSignals(False) @@ -175,6 +200,7 @@ def _create_tab( editor.send_requested.connect(self._on_send_request) 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()) viewer.save_response_requested.connect(self._on_save_response) viewer.save_availability_changed.connect(lambda _enabled: self._refresh_sidebar()) @@ -192,6 +218,7 @@ def _replace_tab( data: RequestLoadDict, *, is_preview: bool = False, + path: str | None = None, ) -> None: """Replace the content of an existing tab with a new request.""" ctx = self._tabs.get(index) @@ -211,13 +238,56 @@ def _replace_tab( index, method=data.get("method", "GET"), name=data.get("name", ""), + path=path, is_preview=is_preview, is_dirty=False, ) + def _request_full_path(self, request_id: int) -> str | None: + """Return the full breadcrumb path for a request tab.""" + crumbs = CollectionService.get_request_breadcrumb(request_id) + if not crumbs: + return None + return " / ".join(str(crumb.get("name", "")) for crumb in crumbs if crumb.get("name")) + + def _next_tab_open_order(self) -> int: + """Return the next creation-order token for a new tab.""" + self._tab_open_counter += 1 + return self._tab_open_counter + + def _next_tab_insert_index(self) -> int: + """Return the insertion index for a new tab according to settings.""" + current = self._tab_bar.currentIndex() + if self._tab_settings_manager.open_new_tabs_at_end or current < 0: + return self._tab_bar.count() + return current + 1 + + def _shift_tabs_for_insert(self, index: int) -> None: + """Shift tab contexts when inserting a tab into the middle.""" + self._tabs = { + (old_idx if old_idx < index else old_idx + 1): ctx + for old_idx, ctx in self._tabs.items() + } + + def _on_editor_dirty_changed(self, dirty: bool) -> None: + """Sync dirty state from the emitting editor back into the tab metadata.""" + sender_fn = cast(Any, getattr(self, "sender", None)) + sender = sender_fn() if callable(sender_fn) else None + if sender is None: + return + for idx, ctx in self._tabs.items(): + if ctx.tab_type == "request" and ctx.editor is sender: + ctx.is_dirty = dirty + self._tab_bar.update_tab(idx, is_dirty=dirty) + break + def _on_tab_changed(self, index: int) -> None: """Switch the stacked widgets when the active tab changes.""" ctx = self._tabs.get(index) + if ctx is not None: + self._tab_activation_counter += 1 + ctx.last_activated_order = self._tab_activation_counter + if ctx is not None and ctx.tab_type == "folder": # Folder tab -- show folder editor, hide response pane if ctx.folder_editor is not None: @@ -264,11 +334,24 @@ def _on_tab_changed(self, index: int) -> None: # that drove the stacked-widget switch. self._refresh_sidebar(ctx) + # Sync collection tree selection to the active tab. + self._sync_tree_selection(ctx) + + def _sync_tree_selection(self, ctx: TabContext | None) -> None: + """Highlight the active tab's item in the collection tree.""" + if ctx is None: + return + if ctx.tab_type == "folder" and ctx.collection_id is not None: + self.collection_widget.select_and_scroll_to(ctx.collection_id, "folder") + elif ctx.request_id is not None: + self.collection_widget.select_and_scroll_to(ctx.request_id, "request") + # ------------------------------------------------------------------ # Tab close # ------------------------------------------------------------------ def _on_tab_close(self, index: int) -> None: """Close a tab and clean up its context.""" + target_old_index = self._target_tab_after_close(index) ctx = self._tabs.pop(index, None) if ctx is None: return @@ -299,6 +382,7 @@ def _on_tab_close(self, index: int) -> None: 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) @@ -328,11 +412,12 @@ def _on_tab_close(self, index: int) -> None: new_tabs[new_idx] = old_ctx self._tabs = new_tabs - # Reset widget references so closed widgets can be collected. - # _on_tab_changed may already have run (triggered by removeTab), - # but the re-indexing above can leave stale refs. Force a sync. - current = self._tab_bar.currentIndex() - self._on_tab_changed(current) + 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()) def _on_tab_double_click(self, index: int) -> None: """Promote a preview tab to a permanent tab.""" @@ -341,6 +426,104 @@ def _on_tab_double_click(self, index: int) -> None: ctx.is_preview = False self._tab_bar.update_tab(index, is_preview=False) + def _target_tab_after_close(self, closing_index: int) -> int | None: + """Return the preferred old-index tab to activate after closing one.""" + if not self._tabs: + return None + + current = self._tab_bar.currentIndex() + if current != closing_index: + return current + + remaining = [idx for idx in self._tabs if idx != closing_index] + if not remaining: + return None + + policy = self._tab_settings_manager.activate_on_close + if policy == "left": + left = [idx for idx in remaining if idx < closing_index] + return max(left) if left else min(remaining) + if policy == "right": + right = [idx for idx in remaining if idx > closing_index] + return min(right) if right else max(remaining) + + best_idx: int | None = None + best_order = -1 + for idx in remaining: + last_activated = self._tabs[idx].last_activated_order + if last_activated > best_order: + best_idx = idx + best_order = last_activated + return best_idx + + @staticmethod + def _normalize_target_index_after_close( + closing_index: int, + target_old_index: int | None, + ) -> int | None: + """Translate a pre-close target index into the post-close index space.""" + if target_old_index is None: + return None + return target_old_index if target_old_index < closing_index else target_old_index - 1 + + def _safe_limit_candidate_indices(self) -> list[int]: + """Return the indices that are eligible for safe auto-close policies.""" + active = self._tab_bar.currentIndex() + eligible: list[int] = [] + for idx, ctx in self._tabs.items(): + if idx == active: + continue + if ctx.tab_type != "request": + continue + if ctx.request_id is None: + continue + if ctx.is_sending or ctx.is_dirty: + continue + eligible.append(idx) + return eligible + + def _find_tab_limit_candidate(self) -> int | None: + """Choose a safe tab to close when the configured limit is exceeded.""" + candidates = self._safe_limit_candidate_indices() + if not candidates: + return None + + policy = self._tab_settings_manager.tab_limit_policy + if policy == "close_unchanged": + return min(candidates) + return min(candidates, key=lambda idx: self._tabs[idx].last_activated_order) + + def _enforce_tab_limit_before_open(self) -> bool: + """Close one safe tab when needed so a new tab can be opened.""" + if self._tab_bar.count() < self._tab_settings_manager.tab_limit: + return True + + candidate = self._find_tab_limit_candidate() + if candidate is not None: + self._on_tab_close(candidate) + return True + + from PySide6.QtWidgets import QMessageBox + + QMessageBox.information( + self, # type: ignore[arg-type] + "Tab limit reached", + "All open tabs are protected. Close a tab manually before opening another request.", + ) + return False + + def _on_tab_reordered(self, from_index: int, to_index: int) -> None: + """Keep logical tab state aligned with the visual tab order after drag-reorder.""" + if from_index == to_index: + return + ordered_indices = sorted(self._tabs) + ordered_contexts = [self._tabs[idx] for idx in ordered_indices] + moved = ordered_contexts.pop(from_index) + ordered_contexts.insert(to_index, moved) + for order, ctx in enumerate(ordered_contexts, start=1): + ctx.opened_order = order + self._tabs = {idx: ctx for idx, ctx in enumerate(ordered_contexts)} + # ------------------------------------------------------------------ # Folder tab management # ------------------------------------------------------------------ @@ -381,10 +564,15 @@ def _open_folder(self, collection_id: int) -> None: return # 2. Open a new folder tab + crumbs = CollectionService.get_collection_breadcrumb(collection_id) + folder_path = " / ".join( + str(crumb.get("name", "")) for crumb in crumbs if crumb.get("name") + ) self._create_folder_tab( collection_id, data, request_count, + path=folder_path, created_at=created_at, updated_at=updated_at, recent_requests=recent_requests, @@ -396,11 +584,15 @@ def _create_folder_tab( data: dict, request_count: int, *, + path: str | None = None, created_at: str | None = None, updated_at: str | None = None, recent_requests: list[dict] | None = None, ) -> int: """Create a new folder tab and switch to it.""" + if not self._enforce_tab_limit_before_open(): + return self._tab_bar.currentIndex() + from ui.request.folder_editor import FolderEditorWidget folder_editor = FolderEditorWidget() @@ -411,13 +603,21 @@ def _create_folder_tab( tab_type="folder", collection_id=collection_id, folder_editor=folder_editor, + opened_order=self._next_tab_open_order(), ) + insert_index = self._next_tab_insert_index() + self._shift_tabs_for_insert(insert_index) + # Block signals while adding the tab to avoid premature # _on_tab_changed before ctx is stored. self._tab_bar.blockSignals(True) try: - idx = self._tab_bar.add_folder_tab(data.get("name", "")) + idx = self._tab_bar.add_folder_tab( + data.get("name", ""), + path=path, + index=insert_index, + ) finally: self._tab_bar.blockSignals(False) @@ -467,7 +667,7 @@ def _on_breadcrumb_rename(self, new_name: str) -> None: # Draft tab — no DB entry yet, update tab name and context only if ctx is not None and ctx.request_id is None and ctx.draft_name is not None: ctx.draft_name = new_name - self._tab_bar.update_tab(idx, name=new_name) + self._tab_bar.update_tab(idx, name=new_name, path=new_name) return seg = self._breadcrumb_bar.last_segment_info @@ -496,14 +696,22 @@ def _sync_name_across_tabs(self, item_type: str, item_id: int, new_name: str) -> """Update the tab label and breadcrumb for any open tab matching the item.""" for idx, ctx in self._tabs.items(): if item_type == "request" and ctx.request_id == item_id: - self._tab_bar.update_tab(idx, name=new_name) + self._tab_bar.update_tab( + idx, + name=new_name, + path=self._request_full_path(item_id), + ) # Refresh breadcrumb if this is the active tab if idx == self._tab_bar.currentIndex(): self._breadcrumb_bar.update_last_segment_text(new_name) elif ( item_type == "folder" and ctx.tab_type == "folder" and ctx.collection_id == item_id ): - self._tab_bar.update_tab(idx, name=new_name) + crumbs = CollectionService.get_collection_breadcrumb(item_id) + folder_path = " / ".join( + str(crumb.get("name", "")) for crumb in crumbs if crumb.get("name") + ) + self._tab_bar.update_tab(idx, name=new_name, path=folder_path) if idx == self._tab_bar.currentIndex(): self._breadcrumb_bar.update_last_segment_text(new_name) diff --git a/src/ui/main_window/window.py b/src/ui/main_window/window.py index c71ca22..477279b 100644 --- a/src/ui/main_window/window.py +++ b/src/ui/main_window/window.py @@ -12,6 +12,7 @@ from ui.request.http_worker import HttpSendWorker from PySide6.QtWidgets import ( + QApplication, QHBoxLayout, QInputDialog, QMainWindow, @@ -43,6 +44,7 @@ from ui.request.response_viewer import ResponseViewerWidget from ui.sidebar import RightSidebar from ui.styling.icons import phi +from ui.styling.tab_settings_manager import TabSettingsManager from ui.styling.theme_manager import ThemeManager logger = logging.getLogger(__name__) @@ -61,10 +63,16 @@ class MainWindow( (collection sidebar | request editor | response viewer | right sidebar rail). """ - def __init__(self, theme_manager: ThemeManager | None = None) -> None: + def __init__( + self, + theme_manager: ThemeManager | None = None, + tab_settings_manager: TabSettingsManager | 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.setWindowTitle("Postmark") self.resize(1200, 800) @@ -75,6 +83,8 @@ def __init__(self, theme_manager: ThemeManager | None = None) -> None: # Navigation history self._history: list[int] = [] # request IDs self._history_index: int = -1 + self._tab_open_counter: int = 0 + self._tab_activation_counter: int = 0 # Per-tab state: tab-bar index -> TabContext self._tabs: dict[int, TabContext] = {} @@ -109,6 +119,7 @@ def __init__(self, theme_manager: ThemeManager | None = None) -> None: self._tab_bar.close_others_requested.connect(self._close_others_tabs) self._tab_bar.close_all_requested.connect(self._close_all_tabs) self._tab_bar.force_close_all_requested.connect(self._close_all_tabs) + self._tab_bar.tab_reordered.connect(self._on_tab_reordered) # Wire collection runner self.run_action.triggered.connect(self._on_run_collection) @@ -231,6 +242,25 @@ def _create_menus(self) -> None: # View menu view_menu = menubar.addMenu("&View") + self._search_tabs_action = QAction("Search &Tabs\u2026", self) + self._search_tabs_action.setShortcut(QKeySequence("Ctrl+P")) + self._search_tabs_action.triggered.connect(self._search_tabs) + view_menu.addAction(self._search_tabs_action) + + self._next_tab_action = QAction("&Next Tab", self) + self._next_tab_action.setShortcuts([QKeySequence("Ctrl+Tab"), QKeySequence("Ctrl+PgDown")]) + self._next_tab_action.triggered.connect(self._activate_next_tab) + view_menu.addAction(self._next_tab_action) + + self._previous_tab_action = QAction("&Previous Tab", self) + self._previous_tab_action.setShortcuts( + [QKeySequence("Ctrl+Shift+Tab"), QKeySequence("Ctrl+PgUp")] + ) + self._previous_tab_action.triggered.connect(self._activate_previous_tab) + view_menu.addAction(self._previous_tab_action) + + view_menu.addSeparator() + self._toggle_response_action = QAction("&Toggle Response Pane", self) self._toggle_response_action.setShortcut(QKeySequence("Ctrl+\\")) self._toggle_response_action.triggered.connect(self._toggle_response_pane) @@ -265,10 +295,12 @@ def _build_request_area(self) -> QWidget: layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - self._tab_bar = RequestTabBar() + self._tab_bar = RequestTabBar(self._tab_settings_manager) self._tab_bar.currentChanged.connect(self._on_tab_changed) self._tab_bar.tab_close_requested.connect(self._on_tab_close) self._tab_bar.tab_double_clicked.connect(self._on_tab_double_click) + if self._theme_manager is not None: + self._theme_manager.theme_changed.connect(self._tab_bar.refresh_theme) layout.addWidget(self._tab_bar) breadcrumb_row = QHBoxLayout() @@ -471,6 +503,51 @@ def _toggle_layout_orientation(self) -> None: else: self._right_splitter.setOrientation(Qt.Orientation.Vertical) + def _activate_next_tab(self) -> None: + """Select the next open tab, wrapping at the end of the deck.""" + self._tab_bar.select_next_tab() + + def _activate_previous_tab(self) -> None: + """Select the previous open tab, wrapping at the start of the deck.""" + self._tab_bar.select_previous_tab() + + def _search_tabs(self) -> None: + """Prompt for an open tab and activate the best matching result.""" + items = [self._tab_bar.tab_search_text(index) for index in range(self._tab_bar.count())] + if not items: + return + + current_index = max(0, self._tab_bar.currentIndex()) + choice, accepted = QInputDialog.getItem( + self, + "Search Tabs", + "Type to filter open tabs", + items, + current_index, + True, + ) + if not accepted: + return + + query = choice.strip().casefold() + if not query: + return + + target_index = next( + (index for index, item in enumerate(items) if item.casefold() == query), + None, + ) + if target_index is None: + target_index = next( + (index for index, item in enumerate(items) if query in item.casefold()), + None, + ) + if target_index is None: + return + + self._tab_bar.setCurrentIndex(target_index) + self._on_tab_changed(target_index) + # ------------------------------------------------------------------ # Dialogs # ------------------------------------------------------------------ @@ -478,9 +555,12 @@ def _on_settings(self) -> None: """Open the settings dialog.""" from ui.dialogs.settings_dialog import SettingsDialog - if self._theme_manager is not None: - dialog = SettingsDialog(self._theme_manager, parent=self) - dialog.exec() + dialog = SettingsDialog( + self._theme_manager, + self._tab_settings_manager, + parent=self, + ) + dialog.exec() # ------------------------------------------------------------------ # Current tab helper diff --git a/src/ui/request/navigation/__init__.py b/src/ui/request/navigation/__init__.py index 4f45e7f..b070c81 100644 --- a/src/ui/request/navigation/__init__.py +++ b/src/ui/request/navigation/__init__.py @@ -1,3 +1,7 @@ """Request tab and breadcrumb navigation widgets.""" from __future__ import annotations + +from .request_tab_bar import RequestTabBar + +__all__ = ["RequestTabBar"] diff --git a/src/ui/request/navigation/request_tab_bar.py b/src/ui/request/navigation/request_tab_bar.py index e0a9cf2..e8650c2 100644 --- a/src/ui/request/navigation/request_tab_bar.py +++ b/src/ui/request/navigation/request_tab_bar.py @@ -1,360 +1,7 @@ -"""Closeable tab bar for open requests with method badges and dirty indicators. - -Each tab shows a short method badge (coloured), the request name, and -optionally a dirty marker. Tabs can be closed, reordered by drag, and -display an italic style when in preview mode. -""" +"""Compatibility wrapper for the wrapped request-tab deck.""" from __future__ import annotations -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QFontMetrics, QMouseEvent -from PySide6.QtWidgets import QHBoxLayout, QLabel, QMenu, QSizePolicy, QTabBar, QWidget - -from ui.styling.icons import phi -from ui.styling.theme import ( - BADGE_BORDER_RADIUS, - BADGE_FONT_SIZE, - BADGE_HEIGHT, - BADGE_MIN_WIDTH, - COLOR_SENDING, - COLOR_WHITE, - method_color, - method_short_label, -) - -# Dirty indicator bullet prefix -_DIRTY_BULLET = "\u2022 " - -# Tab bar height -_TAB_HEIGHT = 30 - -# Maximum display width for tab name labels (pixels) -_MAX_NAME_WIDTH = 180 - -# Maximum length for the tooltip (characters) -_MAX_TOOLTIP_LEN = 300 - - -class _TabLabel(QWidget): - """Custom tab label with a method badge and request name.""" - - def __init__( - self, - method: str = "GET", - name: str = "", - *, - is_preview: bool = False, - is_dirty: bool = False, - parent: QWidget | None = None, - ) -> None: - """Initialise the tab label with method badge and name.""" - super().__init__(parent) - - layout = QHBoxLayout(self) - layout.setContentsMargins(4, 0, 4, 0) - layout.setSpacing(4) - - # Method badge - self._badge = QLabel(method_short_label(method)) - self._badge.setObjectName("methodBadge") - self._badge.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._badge.setFixedSize(BADGE_MIN_WIDTH, BADGE_HEIGHT) - self._apply_badge_color(method_color(method)) - layout.addWidget(self._badge) - - # Request name - self._name_label = QLabel(name) - self._name_label.setMaximumWidth(_MAX_NAME_WIDTH) - self._name_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) - layout.addWidget(self._name_label) - - self._method = method - self._name = name - self._is_preview = is_preview - self._is_dirty = is_dirty - self._is_sending = False - - # Sending spinner indicator - self._spinner = QLabel("\u25cf") - self._spinner.setStyleSheet(f"color: {COLOR_SENDING}; font-size: 10px; padding: 0;") - self._spinner.hide() - layout.addWidget(self._spinner) - - self._apply_style() - - def _apply_badge_color(self, color: str) -> None: - """Set the badge background colour (dynamic per-method).""" - self._badge.setStyleSheet( - f"background: {color}; color: {COLOR_WHITE};" - f" font-size: {BADGE_FONT_SIZE}px; font-weight: bold;" - f" border-radius: {BADGE_BORDER_RADIUS}px;" - f" font-family: monospace;" - ) - - def set_method(self, method: str) -> None: - """Update the method badge.""" - self._method = method - self._badge.setText(method_short_label(method)) - self._apply_badge_color(method_color(method)) - - def set_name(self, name: str) -> None: - """Update the request name.""" - self._name = name - self._apply_style() - - def set_preview(self, preview: bool) -> None: - """Toggle the preview (italic) style.""" - self._is_preview = preview - self._apply_style() - - def set_dirty(self, dirty: bool) -> None: - """Toggle the dirty indicator.""" - self._is_dirty = dirty - self._apply_style() - - def set_sending(self, sending: bool) -> None: - """Toggle the sending spinner indicator.""" - self._is_sending = sending - self._spinner.setVisible(sending) - - def _apply_style(self) -> None: - """Rebuild the display text and font style.""" - prefix = _DIRTY_BULLET if self._is_dirty else "" - full_text = f"{prefix}{self._name}" - - # Elide text if it exceeds the max width - metrics = QFontMetrics(self._name_label.font()) - elided = metrics.elidedText(full_text, Qt.TextElideMode.ElideRight, _MAX_NAME_WIDTH) - self._name_label.setText(elided) - - font = self._name_label.font() - font.setItalic(self._is_preview) - self._name_label.setFont(font) - - -class _FolderTabLabel(QWidget): - """Custom tab label for folder tabs with a folder icon and name.""" - - def __init__( - self, - name: str = "", - *, - is_dirty: bool = False, - parent: QWidget | None = None, - ) -> None: - """Initialise the folder tab label with icon and name.""" - super().__init__(parent) - - layout = QHBoxLayout(self) - layout.setContentsMargins(4, 0, 4, 0) - layout.setSpacing(4) - - # Folder icon - self._icon_label = QLabel() - icon = phi("folder-simple") - self._icon_label.setPixmap(icon.pixmap(BADGE_HEIGHT, BADGE_HEIGHT)) - self._icon_label.setFixedSize(BADGE_MIN_WIDTH, BADGE_HEIGHT) - self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(self._icon_label) - - # Folder name - self._name_label = QLabel(name) - self._name_label.setMaximumWidth(_MAX_NAME_WIDTH) - self._name_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) - layout.addWidget(self._name_label) - - self._name = name - self._is_dirty = is_dirty - - self._apply_style() - - def set_name(self, name: str) -> None: - """Update the folder name.""" - self._name = name - self._apply_style() - - def set_dirty(self, dirty: bool) -> None: - """Toggle the dirty indicator.""" - self._is_dirty = dirty - self._apply_style() - - def _apply_style(self) -> None: - """Rebuild the display text from current state.""" - prefix = _DIRTY_BULLET if self._is_dirty else "" - full_text = f"{prefix}{self._name}" - - metrics = QFontMetrics(self._name_label.font()) - elided = metrics.elidedText(full_text, Qt.TextElideMode.ElideRight, _MAX_NAME_WIDTH) - self._name_label.setText(elided) - - -class RequestTabBar(QTabBar): - """Tab bar for open request tabs with method badges and dirty indicators. - - Signals: - tab_close_requested(int): Emitted when a tab close button is clicked. - tab_double_clicked(int): Emitted on double-click (promote preview). - new_tab_requested(): Emitted when the "+" button is clicked (future). - """ - - tab_close_requested = Signal(int) - tab_double_clicked = Signal(int) - new_tab_requested = Signal() - close_others_requested = Signal(int) - close_all_requested = Signal() - force_close_all_requested = Signal() - - def __init__(self, parent: QWidget | None = None) -> None: - """Initialise the tab bar with close buttons and movable tabs.""" - super().__init__(parent) - self.setMovable(True) - self.setTabsClosable(True) - self.setExpanding(False) - self.setDrawBase(False) - self.setDocumentMode(True) - - self.tabCloseRequested.connect(self.tab_close_requested.emit) - - # Map tab index -> custom label widget (_TabLabel or _FolderTabLabel) - self._tab_labels: dict[int, _TabLabel] = {} - self._folder_labels: dict[int, _FolderTabLabel] = {} - - # -- Public API ---------------------------------------------------- - - def add_request_tab( - self, - method: str, - name: str, - *, - is_preview: bool = False, - ) -> int: - """Add a new tab for a request and return its index. - - Args: - method: HTTP method (for badge). - name: Request name (displayed in tab). - is_preview: Whether this tab is preview-only (italic). - """ - label_widget = _TabLabel(method, name, is_preview=is_preview) - idx = self.addTab("") - self.setTabButton(idx, QTabBar.ButtonPosition.LeftSide, label_widget) - self.setTabToolTip(idx, name[:_MAX_TOOLTIP_LEN]) - self._tab_labels[idx] = label_widget - return idx - - def add_folder_tab(self, name: str) -> int: - """Add a new tab for a folder and return its index. - - Args: - name: Folder name (displayed in tab). - """ - label_widget = _FolderTabLabel(name) - idx = self.addTab("") - self.setTabButton(idx, QTabBar.ButtonPosition.LeftSide, label_widget) - self.setTabToolTip(idx, name[:_MAX_TOOLTIP_LEN]) - self._folder_labels[idx] = label_widget - return idx - - def update_tab( - self, - index: int, - *, - method: str | None = None, - name: str | None = None, - is_preview: bool | None = None, - is_dirty: bool | None = None, - is_sending: bool | None = None, - ) -> None: - """Update properties of an existing tab. - - Only the provided keyword arguments are changed. Works for both - request tabs and folder tabs. - """ - # 1. Try request tab label - label = self._tab_labels.get(index) - if label is not None: - if method is not None: - label.set_method(method) - if name is not None: - label.set_name(name) - self.setTabToolTip(index, name[:_MAX_TOOLTIP_LEN]) - if is_preview is not None: - label.set_preview(is_preview) - if is_dirty is not None: - label.set_dirty(is_dirty) - if is_sending is not None: - label.set_sending(is_sending) - return - - # 2. Try folder tab label - folder_label = self._folder_labels.get(index) - if folder_label is not None: - if name is not None: - folder_label.set_name(name) - self.setTabToolTip(index, name[:_MAX_TOOLTIP_LEN]) - if is_dirty is not None: - folder_label.set_dirty(is_dirty) - - def remove_request_tab(self, index: int) -> None: - """Remove a tab at the given index and clean up its label.""" - self._tab_labels.pop(index, None) - self._folder_labels.pop(index, None) - self.removeTab(index) - # Re-index labels after removal - new_labels: dict[int, _TabLabel] = {} - for old_idx, label in self._tab_labels.items(): - new_idx = old_idx if old_idx < index else old_idx - 1 - new_labels[new_idx] = label - self._tab_labels = new_labels - - new_folder_labels: dict[int, _FolderTabLabel] = {} - for old_idx, flabel in self._folder_labels.items(): - new_idx = old_idx if old_idx < index else old_idx - 1 - new_folder_labels[new_idx] = flabel - self._folder_labels = new_folder_labels - - def tab_label(self, index: int) -> _TabLabel | None: - """Return the label widget for a tab, or ``None``.""" - return self._tab_labels.get(index) - - # -- Event overrides ----------------------------------------------- - - def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: - """Emit double-click signal for tab promotion.""" - index = self.tabAt(event.position().toPoint()) - if index >= 0: - self.tab_double_clicked.emit(index) - super().mouseDoubleClickEvent(event) - - def mousePressEvent(self, event: QMouseEvent) -> None: - """Close tab on middle-click.""" - if event.button() == Qt.MouseButton.MiddleButton: - index = self.tabAt(event.position().toPoint()) - if index >= 0: - self.tab_close_requested.emit(index) - return - super().mousePressEvent(event) - - def contextMenuEvent(self, event: QMouseEvent) -> None: # type: ignore[override] - """Show right-click context menu with Close / Close Others / Close All.""" - index = self.tabAt(event.pos()) - if index < 0: - return - - menu = QMenu(self) - close_act = menu.addAction("Close") - close_others_act = menu.addAction("Close Others") - close_all_act = menu.addAction("Close All") - menu.addSeparator() - force_close_all_act = menu.addAction("Force Close All") +from ui.request.navigation.request_tabs import RequestTabBar - chosen = menu.exec(event.globalPos()) - if chosen == close_act: - self.tab_close_requested.emit(index) - elif chosen == close_others_act: - self.close_others_requested.emit(index) - elif chosen == close_all_act: - self.close_all_requested.emit() - elif chosen == force_close_all_act: - self.force_close_all_requested.emit() +__all__ = ["RequestTabBar"] diff --git a/src/ui/request/navigation/request_tabs/__init__.py b/src/ui/request/navigation/request_tabs/__init__.py new file mode 100644 index 0000000..09d9bee --- /dev/null +++ b/src/ui/request/navigation/request_tabs/__init__.py @@ -0,0 +1,7 @@ +"""Wrapped request-tab deck implementation.""" + +from __future__ import annotations + +from .bar import RequestTabBar + +__all__ = ["RequestTabBar"] diff --git a/src/ui/request/navigation/request_tabs/bar.py b/src/ui/request/navigation/request_tabs/bar.py new file mode 100644 index 0000000..58527f5 --- /dev/null +++ b/src/ui/request/navigation/request_tabs/bar.py @@ -0,0 +1,613 @@ +"""Wrapped multi-row request tab deck.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from PySide6.QtCore import QPoint, QRect, QSize, Qt, Signal +from PySide6.QtGui import QContextMenuEvent, QKeyEvent, QMouseEvent, QResizeEvent, QWheelEvent +from PySide6.QtWidgets import QMenu, QSizePolicy, QTabBar, QWidget + +from .labels import FolderTabLabel, TabLabel, layout_config +from .tab_button import TabButton + +if TYPE_CHECKING: + from ui.styling.tab_settings_manager import TabSettingsManager + +_MAX_TOOLTIP_LEN = 300 +_ROW_GAP = 2 +_TAB_GAP = 2 +_PADDING_X = 4 +_PADDING_Y = 4 +_MIN_SINGLE_ROW_WIDTH = 1 + + +@dataclass +class _TabEntry: + """Single tab entry tracked by the wrapped deck.""" + + tab_type: str + button: TabButton + label: TabLabel | FolderTabLabel + path: str | None = None + + +class RequestTabBar(QWidget): + """Wrapped multi-row top tab deck with a QTabBar-like compatibility API.""" + + currentChanged = Signal(int) + tabCloseRequested = Signal(int) + tab_close_requested = Signal(int) + tab_double_clicked = Signal(int) + new_tab_requested = Signal() + close_others_requested = Signal(int) + close_all_requested = Signal() + force_close_all_requested = Signal() + tab_reordered = Signal(int, int) + + def __init__( + self, + tab_settings_manager: TabSettingsManager | None = None, + parent: QWidget | None = None, + ) -> None: + """Initialise the wrapped tab deck.""" + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self._tab_settings_manager = tab_settings_manager + self._entries: list[_TabEntry] = [] + self._current_index = -1 + self._tabs_closable = True + self._hover_suppressed = False + self._layout_height = layout_config(False).tab_height + (_PADDING_Y * 2) + + self.setMouseTracking(True) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.tabCloseRequested.connect(self.tab_close_requested.emit) + self._apply_settings() + if self._tab_settings_manager is not None: + self._tab_settings_manager.settings_changed.connect(self._apply_settings) + + def count(self) -> int: + """Return the number of open tabs.""" + return len(self._entries) + + def currentIndex(self) -> int: + """Return the current tab index, or ``-1`` when no tab is selected.""" + return self._current_index + + def setCurrentIndex(self, index: int) -> None: + """Select the tab at the given index.""" + new_index = index if 0 <= index < self.count() else -1 + if new_index == self._current_index: + return + self._current_index = new_index + self._sync_selection_styles() + self.currentChanged.emit(new_index) + + def tabsClosable(self) -> bool: + """Return whether tabs expose close affordances.""" + return self._tabs_closable + + def tabRect(self, index: int) -> QRect: + """Return the geometry for the tab at the given index.""" + entry = self._entry(index) + return entry.button.geometry() if entry is not None else QRect() + + def tabAt(self, point: QPoint) -> int: + """Return the tab index at the given point, or ``-1`` when none matches.""" + for index, entry in enumerate(self._entries): + if entry.button.geometry().contains(point): + return index + return -1 + + def tabToolTip(self, index: int) -> str: + """Return the tooltip for the tab at the given index.""" + entry = self._entry(index) + return entry.button.toolTip() if entry is not None else "" + + def setTabToolTip(self, index: int, text: str) -> None: + """Set the tooltip for the tab at the given index.""" + entry = self._entry(index) + if entry is not None: + entry.button.setToolTip(text) + + def tabButton(self, index: int, position: QTabBar.ButtonPosition): + """Return the embedded label or close button for test compatibility.""" + entry = self._entry(index) + if entry is None: + return None + if position == QTabBar.ButtonPosition.RightSide: + return entry.button.close_button() + if position == QTabBar.ButtonPosition.LeftSide: + return entry.label + return None + + def tab_search_text(self, index: int) -> str: + """Return a human-readable tab label for search and jump actions.""" + entry = self._entry(index) + if entry is None: + return "" + if entry.tab_type == "request" and isinstance(entry.label, TabLabel): + text = f"{entry.label._method} {entry.label._name}" + elif isinstance(entry.label, FolderTabLabel): + text = f"Folder {entry.label._name}" + else: + text = "" + if entry.path and entry.path not in text: + return f"{text} - {entry.path}" + return text + + def select_next_tab(self) -> None: + """Activate the next tab, wrapping at the end of the deck.""" + self._cycle_current(1) + + def select_previous_tab(self) -> None: + """Activate the previous tab, wrapping at the start of the deck.""" + self._cycle_current(-1) + + def refresh_theme(self) -> None: + """Refresh button styling after a theme change.""" + for entry in self._entries: + entry.button.refresh_style() + self._sync_selection_styles() + self.update() + + def add_request_tab( + self, + method: str, + name: str, + *, + is_preview: bool = False, + path: str | None = None, + index: int | None = None, + ) -> int: + """Add a new tab for a request and return its index. + + Args: + method: HTTP method badge text. + name: Request name shown in the tab label. + is_preview: Whether the tab uses preview styling. + path: Full breadcrumb path used for duplicate disambiguation and hover text. + index: Optional insertion index within the current deck. + """ + label = TabLabel( + method, + name, + is_preview=is_preview, + compact=self._small_labels, + mark_modified=self._mark_modified, + ) + return self._insert_entry("request", label, path, index) + + def add_folder_tab( + self, + name: str, + *, + path: str | None = None, + index: int | None = None, + ) -> int: + """Add a new tab for a folder and return its index. + + Args: + name: Folder name shown in the tab label. + path: Full breadcrumb path used for hover text. + index: Optional insertion index within the current deck. + """ + label = FolderTabLabel( + name, + compact=self._small_labels, + mark_modified=self._mark_modified, + ) + idx = self._insert_entry("folder", label, path, index) + self._apply_tooltip(idx, name, path) + return idx + + def update_tab( + self, + index: int, + *, + method: str | None = None, + name: str | None = None, + path: str | None = None, + is_preview: bool | None = None, + is_dirty: bool | None = None, + is_sending: bool | None = None, + ) -> None: + """Update properties of an existing tab.""" + entry = self._entry(index) + if entry is None: + return + + if path is not None: + entry.path = path + if entry.tab_type == "request": + request_label = entry.label + assert isinstance(request_label, TabLabel) + if method is not None: + request_label.set_method(method) + if name is not None: + request_label.set_name(name) + if is_preview is not None: + request_label.set_preview(is_preview) + if is_dirty is not None: + request_label.set_dirty(is_dirty) + if is_sending is not None: + request_label.set_sending(is_sending) + self._refresh_request_labels() + return + + folder_label = entry.label + assert isinstance(folder_label, FolderTabLabel) + if name is not None: + folder_label.set_name(name) + if is_dirty is not None: + folder_label.set_dirty(is_dirty) + tooltip_name = name if name is not None else folder_label._name + self._apply_tooltip(index, tooltip_name, entry.path) + self._relayout_tabs() + + def remove_request_tab(self, index: int) -> None: + """Remove a tab at the given index and clean up its widgets.""" + if not 0 <= index < self.count(): + return + entry = self._entries.pop(index) + entry.button.setParent(None) + entry.button.deleteLater() + + if not self._entries: + self._current_index = -1 + elif self._current_index > index: + self._current_index -= 1 + elif self._current_index >= self.count(): + self._current_index = self.count() - 1 + + self._reindex_entries() + self._refresh_request_labels() + self._relayout_tabs() + self._sync_selection_styles() + + def move_tab(self, from_index: int, to_index: int) -> None: + """Move a tab to a new position and emit the reorder signal.""" + if not 0 <= from_index < self.count() or not 0 <= to_index < self.count(): + return + if from_index == to_index: + return + + entry = self._entries.pop(from_index) + self._entries.insert(to_index, entry) + + current_index = self._current_index + new_current = current_index + if current_index == from_index: + new_current = to_index + elif from_index < current_index <= to_index: + new_current = current_index - 1 + elif to_index <= current_index < from_index: + new_current = current_index + 1 + + self._reindex_entries() + self._refresh_request_labels() + self._relayout_tabs() + self.tab_reordered.emit(from_index, to_index) + self._current_index = new_current + self._sync_selection_styles() + if new_current != current_index: + self.currentChanged.emit(new_current) + + def tab_label(self, index: int) -> TabLabel | None: + """Return the request-tab label widget for the given index, or ``None``.""" + entry = self._entry(index) + if entry is None or entry.tab_type != "request": + return None + label = entry.label + return label if isinstance(label, TabLabel) else None + + def resizeEvent(self, event: QResizeEvent) -> None: + """Reflow the tab chips whenever the deck width changes.""" + super().resizeEvent(event) + self._relayout_tabs() + + def showEvent(self, event) -> None: # type: ignore[override] + """Lay out the current tabs when the deck becomes visible.""" + super().showEvent(event) + self._relayout_tabs() + + def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: + """Promote preview tabs when the user double-clicks a chip body.""" + index = self.tabAt(event.position().toPoint()) + if index >= 0: + self.tab_double_clicked.emit(index) + super().mouseDoubleClickEvent(event) + + def mousePressEvent(self, event: QMouseEvent) -> None: + """Close tabs on middle-click and select them on left-click.""" + index = self.tabAt(event.position().toPoint()) + if index >= 0 and event.button() == Qt.MouseButton.MiddleButton: + self.tab_close_requested.emit(index) + event.accept() + return + if index >= 0 and event.button() == Qt.MouseButton.LeftButton: + self.setCurrentIndex(index) + super().mousePressEvent(event) + + def keyPressEvent(self, event: QKeyEvent) -> None: + """Support arrow-key tab traversal when the wrapped deck has focus.""" + if event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Up): + self.select_previous_tab() + event.accept() + return + if event.key() in (Qt.Key.Key_Right, Qt.Key.Key_Down): + self.select_next_tab() + event.accept() + return + if event.key() == Qt.Key.Key_Home and self.count() > 0: + self.setCurrentIndex(0) + event.accept() + return + if event.key() == Qt.Key.Key_End and self.count() > 0: + self.setCurrentIndex(self.count() - 1) + event.accept() + return + super().keyPressEvent(event) + + def wheelEvent(self, event: QWheelEvent) -> None: + """Cycle through tabs on mouse wheel scroll.""" + if self.count() < 2: + return + for entry in self._entries: + entry.button.suppress_hover() + self._hover_suppressed = True + delta = event.angleDelta().y() + if delta > 0: + self._cycle_current(-1) + elif delta < 0: + self._cycle_current(1) + event.accept() + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + """Restore hover visuals after a wheel-scroll suppression.""" + if self._hover_suppressed: + self._hover_suppressed = False + for entry in self._entries: + entry.button.restore_hover() + super().mouseMoveEvent(event) + + def contextMenuEvent(self, event: QContextMenuEvent) -> None: + """Show the tab context menu when right-clicking the deck.""" + index = self.tabAt(event.pos()) + if index < 0: + return + self._show_context_menu(index, event.globalPos()) + event.accept() + + def sizeHint(self) -> QSize: # type: ignore[override] + """Return a size hint that tracks the wrapped deck height.""" + return QSize(max(240, self.width()), self._layout_height) + + def minimumSizeHint(self) -> QSize: # type: ignore[override] + """Return the minimum size for the wrapped deck.""" + return QSize(120, self._layout_height) + + def _insert_entry( + self, + tab_type: str, + label: TabLabel | FolderTabLabel, + path: str | None, + index: int | None, + ) -> int: + """Insert a generic tab entry and return its index.""" + idx = self.count() if index is None or index >= self.count() else max(index, 0) + button = TabButton(idx, label, self) + button.clicked.connect(self.setCurrentIndex) + button.close_requested.connect(self.tab_close_requested.emit) + button.double_clicked.connect(self.tab_double_clicked.emit) + button.reorder_requested.connect(self.move_tab) + button.context_requested.connect(self._show_context_menu) + button.show() + + self._entries.insert( + idx, _TabEntry(tab_type=tab_type, button=button, label=label, path=path) + ) + self._reindex_entries() + self._refresh_request_labels() + self._relayout_tabs() + self._sync_selection_styles() + return idx + + def _entry(self, index: int) -> _TabEntry | None: + """Return the tab entry at the given index, or ``None``.""" + if 0 <= index < self.count(): + return self._entries[index] + return None + + def _reindex_entries(self) -> None: + """Synchronise chip indices after insert/remove/reorder.""" + for index, entry in enumerate(self._entries): + entry.button.set_index(index) + + def _apply_tooltip(self, index: int, name: str, path: str | None) -> None: + """Update the tooltip for the given tab according to settings.""" + tooltip = path if self._show_full_path_on_hover and path else name + self.setTabToolTip(index, tooltip[:_MAX_TOOLTIP_LEN]) + + @staticmethod + def _short_path_label(path: str | None) -> str | None: + """Return a compact parent-path label for duplicate request names.""" + if not path: + return None + parts = [part.strip() for part in path.split(" / ") if part.strip()] + if len(parts) <= 1: + return path + return parts[-2] + + def _refresh_request_labels(self) -> None: + """Refresh request label text and tooltips after name/settings changes.""" + counts: dict[str, int] = {} + for entry in self._entries: + if entry.tab_type == "request" and isinstance(entry.label, TabLabel): + counts[entry.label._name] = counts.get(entry.label._name, 0) + 1 + + for index, entry in enumerate(self._entries): + if entry.tab_type != "request" or not isinstance(entry.label, TabLabel): + if entry.tab_type == "folder" and isinstance(entry.label, FolderTabLabel): + self._apply_tooltip(index, entry.label._name, entry.path) + continue + display_name = entry.label._name + if self._show_path_for_duplicates and counts.get(entry.label._name, 0) > 1: + short_path = self._short_path_label(entry.path) + if short_path: + display_name = f"{entry.label._name} ({short_path})" + entry.label.set_display_name(display_name) + self._apply_tooltip(index, entry.label._name, entry.path) + self._relayout_tabs() + + def _apply_settings(self) -> None: + """Refresh the wrapped deck rendering from the persisted tab settings.""" + self._small_labels = bool( + self._tab_settings_manager.small_labels if self._tab_settings_manager else True + ) + self._mark_modified = bool( + self._tab_settings_manager.mark_modified if self._tab_settings_manager else True + ) + self._wrap_mode = str( + self._tab_settings_manager.wrap_mode if self._tab_settings_manager else "multiple_rows" + ) + self._show_full_path_on_hover = bool( + self._tab_settings_manager.show_full_path_on_hover + if self._tab_settings_manager + else True + ) + self._show_path_for_duplicates = bool( + self._tab_settings_manager.show_path_for_duplicates + if self._tab_settings_manager + else True + ) + + for entry in self._entries: + entry.label.apply_config(compact=self._small_labels, mark_modified=self._mark_modified) + entry.button.refresh_style() + self._refresh_request_labels() + self._sync_selection_styles() + + def _sync_selection_styles(self) -> None: + """Update chip selection styling after the active index changes.""" + for index, entry in enumerate(self._entries): + entry.button.set_selected(index == self._current_index) + + def _cycle_current(self, step: int) -> None: + """Move the current index forward or backward, wrapping around.""" + count = self.count() + if count <= 0: + return + current = self._current_index if self._current_index >= 0 else 0 + self.setCurrentIndex((current + step) % count) + + @staticmethod + def _fit_single_row_widths(base_widths: list[int], available_width: int) -> list[int]: + """Compress tab widths to fit a single visible row.""" + if not base_widths: + return [] + if sum(base_widths) <= available_width: + return base_widths + + total = sum(base_widths) + scaled = [ + max(_MIN_SINGLE_ROW_WIDTH, (width * available_width) // total) for width in base_widths + ] + assigned = sum(scaled) + remainder = available_width - assigned + + index = 0 + while remainder > 0: + scaled[index % len(scaled)] += 1 + remainder -= 1 + index += 1 + + index = 0 + while remainder < 0: + target = index % len(scaled) + if scaled[target] > _MIN_SINGLE_ROW_WIDTH: + scaled[target] -= 1 + remainder += 1 + index += 1 + + return scaled + + def _relayout_single_row(self) -> None: + """Lay out every tab on a single compressed row.""" + content = self.contentsRect() + available_width = max(1, content.width() - (_PADDING_X * 2)) + available_for_tabs = max(1, available_width - (_TAB_GAP * max(0, self.count() - 1))) + base_widths = [entry.button.sizeHint().width() for entry in self._entries] + widths = self._fit_single_row_widths(base_widths, available_for_tabs) + row_height = max(entry.button.sizeHint().height() for entry in self._entries) + + x = content.x() + _PADDING_X + y = content.y() + _PADDING_Y + for entry, width in zip(self._entries, widths, strict=False): + entry.button.setGeometry(x, y, width, row_height) + x += width + _TAB_GAP + + total_height = y + row_height + _PADDING_Y + if total_height != self._layout_height: + self._layout_height = total_height + self.setFixedHeight(total_height) + self.updateGeometry() + + def _relayout_tabs(self) -> None: + """Wrap the tab chips across multiple rows based on the current width.""" + if not self._entries: + self._layout_height = layout_config(self._small_labels).tab_height + (_PADDING_Y * 2) + self.setFixedHeight(self._layout_height) + return + + if self._wrap_mode == "single_row": + self._relayout_single_row() + return + + content = self.contentsRect() + available_width = max(1, content.width() - (_PADDING_X * 2)) + x = content.x() + _PADDING_X + y = content.y() + _PADDING_Y + row_height = 0 + row_start = x + + for entry in self._entries: + hint = entry.button.sizeHint() + width = min(max(hint.width(), 92), available_width) + height = hint.height() + if x > row_start and x + width > row_start + available_width: + x = row_start + y += row_height + _ROW_GAP + row_height = 0 + entry.button.setGeometry(x, y, width, height) + x += width + _TAB_GAP + row_height = max(row_height, height) + + total_height = y + row_height + _PADDING_Y + if total_height != self._layout_height: + self._layout_height = total_height + self.setFixedHeight(total_height) + self.updateGeometry() + + def _show_context_menu(self, index: int, global_pos: QPoint) -> None: + """Show the standard tab context menu for the given index.""" + menu = QMenu(self) + close_act = menu.addAction("Close") + close_others_act = menu.addAction("Close Others") + close_all_act = menu.addAction("Close All") + menu.addSeparator() + force_close_all_act = menu.addAction("Force Close All") + + chosen = menu.exec(global_pos) + if chosen == close_act: + self.tab_close_requested.emit(index) + elif chosen == close_others_act: + self.close_others_requested.emit(index) + elif chosen == close_all_act: + self.close_all_requested.emit() + elif chosen == force_close_all_act: + self.force_close_all_requested.emit() + self.force_close_all_requested.emit() diff --git a/src/ui/request/navigation/request_tabs/labels.py b/src/ui/request/navigation/request_tabs/labels.py new file mode 100644 index 0000000..b0716be --- /dev/null +++ b/src/ui/request/navigation/request_tabs/labels.py @@ -0,0 +1,278 @@ +"""Label widgets used by the wrapped request-tab deck.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from PySide6.QtCore import Qt +from PySide6.QtGui import QFontMetrics +from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QWidget + +from ui.styling.icons import phi +from ui.styling.theme import ( + BADGE_BORDER_RADIUS, + BADGE_FONT_SIZE, + BADGE_HEIGHT, + BADGE_MIN_WIDTH, + COLOR_SENDING, + COLOR_WHITE, + method_color, + method_short_label, +) + +_DIRTY_BULLET = "\u2022 " + + +@dataclass(frozen=True) +class TabLayoutConfig: + """Layout constants for request and folder tab labels.""" + + tab_height: int + label_width: int + badge_width: int + badge_height: int + margins: tuple[int, int, int, int] + spacing: int + font_delta: int + spinner_size: int + + +STANDARD_LAYOUT = TabLayoutConfig( + tab_height=30, + label_width=180, + badge_width=BADGE_MIN_WIDTH, + badge_height=BADGE_HEIGHT, + margins=(4, 0, 4, 0), + spacing=4, + font_delta=0, + spinner_size=10, +) + +COMPACT_LAYOUT = TabLayoutConfig( + tab_height=26, + label_width=148, + badge_width=max(18, BADGE_MIN_WIDTH - 4), + badge_height=max(16, BADGE_HEIGHT - 2), + margins=(3, 0, 3, 0), + spacing=3, + font_delta=-1, + spinner_size=9, +) + + +def layout_config(compact: bool) -> TabLayoutConfig: + """Return the active layout config for tab labels.""" + return COMPACT_LAYOUT if compact else STANDARD_LAYOUT + + +def _font_with_delta(label: QLabel, delta: int) -> None: + """Apply a point-size delta to the given label font.""" + font = label.font() + current_size = font.pointSize() + if current_size <= 0: + current_size = 10 + font.setPointSize(max(8, current_size + delta)) + label.setFont(font) + + +class TabLabel(QWidget): + """Custom request-tab label with a method badge and request name.""" + + def __init__( + self, + method: str = "GET", + name: str = "", + *, + is_preview: bool = False, + is_dirty: bool = False, + compact: bool = False, + mark_modified: bool = True, + parent: QWidget | None = None, + ) -> None: + """Initialise the tab label with method badge and request name.""" + super().__init__(parent) + + self._layout = QHBoxLayout(self) + self._config = layout_config(compact) + self._layout.setContentsMargins(*self._config.margins) + self._layout.setSpacing(self._config.spacing) + + self._badge = QLabel(method_short_label(method)) + self._badge.setObjectName("methodBadge") + self._badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._badge.setFixedSize(self._config.badge_width, self._config.badge_height) + self._apply_badge_color(method_color(method)) + self._layout.addWidget(self._badge) + + self._name_label = QLabel(name) + self._name_label.setMaximumWidth(self._config.label_width) + self._name_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + _font_with_delta(self._name_label, self._config.font_delta) + self._layout.addWidget(self._name_label) + + self._method = method + self._name = name + self._display_name = name + self._is_preview = is_preview + self._is_dirty = is_dirty + self._is_sending = False + self._mark_modified = mark_modified + + self._spinner = QLabel("\u25cf") + self._spinner.setStyleSheet( + f"color: {COLOR_SENDING}; font-size: {self._config.spinner_size}px; padding: 0;" + ) + self._spinner.hide() + self._layout.addWidget(self._spinner) + + self._apply_style() + + def _apply_badge_color(self, color: str) -> None: + """Set the badge background colour.""" + self._badge.setStyleSheet( + f"background: {color}; color: {COLOR_WHITE};" + f" font-size: {BADGE_FONT_SIZE}px; font-weight: bold;" + f" border-radius: {BADGE_BORDER_RADIUS}px;" + f" font-family: monospace;" + ) + + def set_method(self, method: str) -> None: + """Update the method badge.""" + self._method = method + self._badge.setText(method_short_label(method)) + self._apply_badge_color(method_color(method)) + + def set_name(self, name: str) -> None: + """Update the request name.""" + self._name = name + self._display_name = name + self._apply_style() + + def set_display_name(self, name: str) -> None: + """Update the rendered request name without changing the base name.""" + self._display_name = name + self._apply_style() + + def set_preview(self, preview: bool) -> None: + """Toggle the preview style.""" + self._is_preview = preview + self._apply_style() + + def set_dirty(self, dirty: bool) -> None: + """Toggle the dirty marker.""" + self._is_dirty = dirty + self._apply_style() + + def set_sending(self, sending: bool) -> None: + """Toggle the sending indicator.""" + self._is_sending = sending + self._spinner.setVisible(sending) + + def apply_config(self, *, compact: bool, mark_modified: bool) -> None: + """Apply refreshed display config from the tab settings.""" + self._config = layout_config(compact) + self._mark_modified = mark_modified + self._layout.setContentsMargins(*self._config.margins) + self._layout.setSpacing(self._config.spacing) + self._badge.setFixedSize(self._config.badge_width, self._config.badge_height) + self._name_label.setMaximumWidth(self._config.label_width) + _font_with_delta(self._name_label, self._config.font_delta) + self._spinner.setStyleSheet( + f"color: {COLOR_SENDING}; font-size: {self._config.spinner_size}px; padding: 0;" + ) + self._apply_style() + + def _apply_style(self) -> None: + """Rebuild the display text and font style.""" + prefix = _DIRTY_BULLET if self._is_dirty and self._mark_modified else "" + full_text = f"{prefix}{self._display_name}" + metrics = QFontMetrics(self._name_label.font()) + self._name_label.setText( + metrics.elidedText(full_text, Qt.TextElideMode.ElideRight, self._config.label_width) + ) + font = self._name_label.font() + font.setItalic(self._is_preview) + self._name_label.setFont(font) + + +class FolderTabLabel(QWidget): + """Custom folder-tab label with icon and folder name.""" + + def __init__( + self, + name: str = "", + *, + is_dirty: bool = False, + compact: bool = False, + mark_modified: bool = True, + parent: QWidget | None = None, + ) -> None: + """Initialise the folder-tab label.""" + super().__init__(parent) + + self._layout = QHBoxLayout(self) + self._config = layout_config(compact) + self._layout.setContentsMargins(*self._config.margins) + self._layout.setSpacing(self._config.spacing) + + self._icon_label = QLabel() + icon = phi("folder-simple") + self._icon_label.setPixmap( + icon.pixmap(self._config.badge_height, self._config.badge_height) + ) + self._icon_label.setFixedSize(self._config.badge_width, self._config.badge_height) + self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._layout.addWidget(self._icon_label) + + self._name_label = QLabel(name) + self._name_label.setMaximumWidth(self._config.label_width) + self._name_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + _font_with_delta(self._name_label, self._config.font_delta) + self._layout.addWidget(self._name_label) + + self._name = name + self._display_name = name + self._is_dirty = is_dirty + self._mark_modified = mark_modified + + self._apply_style() + + def set_name(self, name: str) -> None: + """Update the folder name.""" + self._name = name + self._display_name = name + self._apply_style() + + def set_display_name(self, name: str) -> None: + """Update the rendered folder name without changing the base name.""" + self._display_name = name + self._apply_style() + + def set_dirty(self, dirty: bool) -> None: + """Toggle the dirty marker.""" + self._is_dirty = dirty + self._apply_style() + + def apply_config(self, *, compact: bool, mark_modified: bool) -> None: + """Apply refreshed display config from the tab settings.""" + self._config = layout_config(compact) + self._mark_modified = mark_modified + self._layout.setContentsMargins(*self._config.margins) + self._layout.setSpacing(self._config.spacing) + icon = phi("folder-simple") + self._icon_label.setPixmap( + icon.pixmap(self._config.badge_height, self._config.badge_height) + ) + self._icon_label.setFixedSize(self._config.badge_width, self._config.badge_height) + self._name_label.setMaximumWidth(self._config.label_width) + _font_with_delta(self._name_label, self._config.font_delta) + self._apply_style() + + def _apply_style(self) -> None: + """Rebuild the display text from the current state.""" + prefix = _DIRTY_BULLET if self._is_dirty and self._mark_modified else "" + full_text = f"{prefix}{self._display_name}" + metrics = QFontMetrics(self._name_label.font()) + self._name_label.setText( + metrics.elidedText(full_text, Qt.TextElideMode.ElideRight, self._config.label_width) + ) diff --git a/src/ui/request/navigation/request_tabs/tab_button.py b/src/ui/request/navigation/request_tabs/tab_button.py new file mode 100644 index 0000000..2ae7abb --- /dev/null +++ b/src/ui/request/navigation/request_tabs/tab_button.py @@ -0,0 +1,203 @@ +"""Interactive chip widget used by the wrapped request-tab deck.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from PySide6.QtCore import QPoint, Qt, Signal +from PySide6.QtGui import QContextMenuEvent, QMouseEvent +from PySide6.QtWidgets import QFrame, QHBoxLayout, QToolButton, QWidget + +from ui.styling import theme as ui_theme + +if TYPE_CHECKING: + from .bar import RequestTabBar + +_BUTTON_PADDING = 18 +_MIN_TAB_WIDTH = 92 + + +class TabButton(QFrame): + """Interactive chip that hosts a tab label widget and close button.""" + + clicked = Signal(int) + close_requested = Signal(int) + double_clicked = Signal(int) + reorder_requested = Signal(int, int) + context_requested = Signal(int, QPoint) + + def __init__(self, index: int, label_widget: QWidget, parent: QWidget | None = None) -> None: + """Initialise the tab chip around a label widget.""" + super().__init__(parent) + self._index = index + self._label_widget = label_widget + self._selected = False + self._hovered = False + self._hover_suppressed = False + self._press_pos: QPoint | None = None + self._drag_active = False + + self.setMouseTracking(True) + + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 3, 4, 3) + layout.setSpacing(6) + layout.addWidget(label_widget, 1) + + self._close_button = QToolButton(self) + self._close_button.setText("\u00d7") + self._close_button.setCursor(Qt.CursorShape.PointingHandCursor) + self._close_button.setAutoRaise(True) + self._close_button.setFixedSize(18, 18) + self._close_button.clicked.connect(self._emit_close_requested) + layout.addWidget(self._close_button) + + self.refresh_style() + + def set_index(self, index: int) -> None: + """Update the chip index after insert/remove/reorder.""" + self._index = index + + def set_selected(self, selected: bool) -> None: + """Update selected state and refresh the chip style.""" + self._selected = selected + self.refresh_style() + + def close_button(self) -> QToolButton: + """Return the close button for compatibility with old tests.""" + return self._close_button + + def label_widget(self) -> QWidget: + """Return the embedded label widget.""" + return self._label_widget + + def refresh_style(self) -> None: + """Refresh dynamic per-state styling from the active palette.""" + background = "transparent" + border = ui_theme.COLOR_BORDER + text = ui_theme.COLOR_TEXT_MUTED + bottom_width = "1px" + bottom_border = ui_theme.COLOR_BORDER + if self._selected: + background = ui_theme.COLOR_SELECTED_BG + text = ui_theme.COLOR_TEXT + bottom_width = "2px" + bottom_border = ui_theme.COLOR_ACCENT + elif self._hovered: + background = ui_theme.COLOR_SELECTED_BG + text = ui_theme.COLOR_TEXT + + self.setStyleSheet( + "TabButton {" + f"background: {background};" + f"border: 1px solid {border};" + f"border-bottom: {bottom_width} solid {bottom_border};" + "border-radius: 4px;" + "}" + ) + self._close_button.setStyleSheet( + "QToolButton {" + "background: transparent;" + "border: none;" + f"color: {text};" + "font-size: 13px;" + "font-weight: bold;" + "}" + f"QToolButton:hover {{ color: {ui_theme.COLOR_TEXT}; }}" + ) + + def sizeHint(self): # type: ignore[override] + """Return a stable chip size for wrapped-row layout.""" + hint = super().sizeHint() + return hint.expandedTo(self.minimumSizeHint()) + + def minimumSizeHint(self): # type: ignore[override] + """Return a compact minimum size that still fits the controls.""" + label_hint = self._label_widget.sizeHint() + close_hint = self._close_button.sizeHint() + width = max(_MIN_TAB_WIDTH, label_hint.width() + close_hint.width() + _BUTTON_PADDING) + height = max(label_hint.height(), close_hint.height()) + 8 + from PySide6.QtCore import QSize + + return QSize(width, height) + + def _emit_close_requested(self) -> None: + """Emit the tab-close signal for the current chip index.""" + self.close_requested.emit(self._index) + + def _parent_bar(self) -> RequestTabBar | None: + """Return the parent wrapped tab deck when present.""" + parent = self.parentWidget() + if parent is not None and parent.metaObject().className() == "RequestTabBar": + return cast("RequestTabBar", parent) + return None + + def suppress_hover(self) -> None: + """Suppress the hover visual until the mouse moves again.""" + self._hover_suppressed = True + if self._hovered: + self._hovered = False + self.refresh_style() + + def restore_hover(self) -> None: + """Re-enable hover visuals after suppression.""" + self._hover_suppressed = False + + def enterEvent(self, event) -> None: # type: ignore[override] + """Refresh hover state when the cursor enters the chip.""" + if not self._hover_suppressed: + self._hovered = True + self.refresh_style() + super().enterEvent(event) + + def leaveEvent(self, event) -> None: # type: ignore[override] + """Refresh hover state when the cursor leaves the chip.""" + self._hovered = False + self.refresh_style() + super().leaveEvent(event) + + def mousePressEvent(self, event: QMouseEvent) -> None: + """Select the tab on left click and close it on middle click.""" + if event.button() == Qt.MouseButton.MiddleButton: + self.close_requested.emit(self._index) + event.accept() + return + if event.button() == Qt.MouseButton.LeftButton: + self._press_pos = event.position().toPoint() + self._drag_active = False + self.clicked.emit(self._index) + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + """Mark the interaction as a drag once the cursor passes the threshold.""" + if ( + self._press_pos is not None + and event.buttons() & Qt.MouseButton.LeftButton + and (event.position().toPoint() - self._press_pos).manhattanLength() >= 8 + ): + self._drag_active = True + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + """Emit a reorder request when a drag ends above another tab.""" + if event.button() == Qt.MouseButton.LeftButton and self._drag_active: + parent = self._parent_bar() + if parent is not None: + drop_pos = parent.mapFromGlobal(event.globalPosition().toPoint()) + target = parent.tabAt(drop_pos) + if target >= 0 and target != self._index: + self.reorder_requested.emit(self._index, target) + self._press_pos = None + self._drag_active = False + super().mouseReleaseEvent(event) + + def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: + """Promote preview tabs on double click.""" + if event.button() == Qt.MouseButton.LeftButton: + self.double_clicked.emit(self._index) + super().mouseDoubleClickEvent(event) + + def contextMenuEvent(self, event: QContextMenuEvent) -> None: + """Delegate the context menu request back to the wrapped tab deck.""" + self.context_requested.emit(self._index, event.globalPos()) + event.accept() diff --git a/src/ui/request/navigation/tab_manager.py b/src/ui/request/navigation/tab_manager.py index 2b1376e..6846190 100644 --- a/src/ui/request/navigation/tab_manager.py +++ b/src/ui/request/navigation/tab_manager.py @@ -54,6 +54,11 @@ class TabContext: also records the original source so the popup can offer **Update** (persist globally) and **Reset** (remove override). Cleared when the tab is closed. + opened_order: Monotonic token tracking creation order. Used by + tab-closing policies such as "Close unchanged". + last_activated_order: Monotonic token tracking recent activation. + Used by policies such as "Close unused" and + "Activate most recently used tab on close". """ def __init__( @@ -66,6 +71,7 @@ def __init__( folder_editor: FolderEditorWidget | None = None, response_viewer: ResponseViewerWidget | None = None, is_preview: bool = False, + opened_order: int = 0, ) -> None: """Create a new tab context with optional pre-built widgets.""" self.tab_type = tab_type @@ -81,6 +87,8 @@ def __init__( self.is_preview: bool = is_preview self.draft_name: str | None = None self.local_overrides: dict[str, LocalOverride] = {} + self.opened_order: int = opened_order + self.last_activated_order: int = 0 # -- Send lifecycle ------------------------------------------------ diff --git a/src/ui/styling/tab_settings_manager.py b/src/ui/styling/tab_settings_manager.py new file mode 100644 index 0000000..475142b --- /dev/null +++ b/src/ui/styling/tab_settings_manager.py @@ -0,0 +1,228 @@ +"""Tab settings manager — reads/writes QSettings for request-tab behaviour. + +Instantiate once in ``main.py`` right after ``QApplication`` is created. +Widgets that render or manage request tabs subscribe to +``settings_changed`` and refresh themselves live when preferences change. +""" + +from __future__ import annotations + +from PySide6.QtCore import QObject, Signal + +from ui.styling.theme_manager import _APP, _ORG + +_KEY_SMALL_LABELS = "tabs/small_labels" +_KEY_SHOW_PATH_FOR_DUPLICATES = "tabs/show_path_for_duplicates" +_KEY_MARK_MODIFIED = "tabs/mark_modified" +_KEY_SHOW_FULL_PATH_ON_HOVER = "tabs/show_full_path_on_hover" +_KEY_OPEN_NEW_AT_END = "tabs/open_new_at_end" +_KEY_ENABLE_PREVIEW = "tabs/enable_preview" +_KEY_TAB_LIMIT = "tabs/tab_limit" +_KEY_TAB_LIMIT_POLICY = "tabs/tab_limit_policy" +_KEY_ACTIVATE_ON_CLOSE = "tabs/activate_on_close" +_KEY_WRAP_MODE = "tabs/wrap_mode" + +LIMIT_CLOSE_UNCHANGED = "close_unchanged" +LIMIT_CLOSE_UNUSED = "close_unused" +LIMIT_POLICIES = (LIMIT_CLOSE_UNCHANGED, LIMIT_CLOSE_UNUSED) + +ACTIVATE_LEFT = "left" +ACTIVATE_RIGHT = "right" +ACTIVATE_MRU = "mru" +ACTIVATE_POLICIES = (ACTIVATE_LEFT, ACTIVATE_RIGHT, ACTIVATE_MRU) + +WRAP_MULTIPLE_ROWS = "multiple_rows" +WRAP_SINGLE_ROW = "single_row" +WRAP_MODES = (WRAP_MULTIPLE_ROWS, WRAP_SINGLE_ROW) + +MIN_TAB_LIMIT = 1 +MAX_TAB_LIMIT = 100 +DEFAULT_TAB_LIMIT = 30 + + +def _as_bool(value: object, default: bool) -> bool: + """Return a stable bool from ``QSettings`` values.""" + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() not in {"0", "false", "no", "off", ""} + return bool(value) + + +def _clamp_tab_limit(value: object) -> int: + """Clamp a configured tab limit into the supported range.""" + try: + parsed = int(str(value)) + except (TypeError, ValueError): + parsed = DEFAULT_TAB_LIMIT + return max(MIN_TAB_LIMIT, min(MAX_TAB_LIMIT, parsed)) + + +class TabSettingsManager(QObject): + """Singleton-style manager for persisted request-tab preferences.""" + + settings_changed = Signal() + + def __init__(self, parent: QObject | None = None) -> None: + """Initialise and immediately read the persisted tab settings.""" + super().__init__(parent) + + from PySide6.QtCore import QSettings + + self._settings = QSettings(_ORG, _APP) + self._small_labels = _as_bool(self._settings.value(_KEY_SMALL_LABELS, True), True) + self._show_path_for_duplicates = _as_bool( + self._settings.value(_KEY_SHOW_PATH_FOR_DUPLICATES, True), + True, + ) + self._mark_modified = _as_bool(self._settings.value(_KEY_MARK_MODIFIED, True), True) + self._show_full_path_on_hover = _as_bool( + self._settings.value(_KEY_SHOW_FULL_PATH_ON_HOVER, True), + True, + ) + self._open_new_tabs_at_end = _as_bool( + self._settings.value(_KEY_OPEN_NEW_AT_END, True), True + ) + self._enable_preview_tab = _as_bool(self._settings.value(_KEY_ENABLE_PREVIEW, True), True) + self._tab_limit = _clamp_tab_limit(self._settings.value(_KEY_TAB_LIMIT, DEFAULT_TAB_LIMIT)) + self._tab_limit_policy = self._read_choice( + _KEY_TAB_LIMIT_POLICY, + LIMIT_CLOSE_UNUSED, + LIMIT_POLICIES, + ) + self._activate_on_close = self._read_choice( + _KEY_ACTIVATE_ON_CLOSE, + ACTIVATE_MRU, + ACTIVATE_POLICIES, + ) + self._wrap_mode = self._read_choice( + _KEY_WRAP_MODE, + WRAP_MULTIPLE_ROWS, + WRAP_MODES, + ) + + def _read_choice(self, key: str, default: str, choices: tuple[str, ...]) -> str: + """Read a string choice and fall back when an invalid value is stored.""" + value = str(self._settings.value(key, default)) + return value if value in choices else default + + def _set_and_emit(self, key: str, attr_name: str, value: object) -> None: + """Persist a changed setting and notify listeners.""" + if getattr(self, attr_name) == value: + return + setattr(self, attr_name, value) + self._settings.setValue(key, value) + self.settings_changed.emit() + + @property + def small_labels(self) -> bool: + """Return whether tabs use the compact label treatment.""" + return self._small_labels + + @small_labels.setter + def small_labels(self, value: bool) -> None: + """Persist the compact-label preference.""" + self._set_and_emit(_KEY_SMALL_LABELS, "_small_labels", bool(value)) + + @property + def show_path_for_duplicates(self) -> bool: + """Return whether duplicate tab names show a path suffix.""" + return self._show_path_for_duplicates + + @show_path_for_duplicates.setter + def show_path_for_duplicates(self, value: bool) -> None: + """Persist duplicate-name path disambiguation.""" + self._set_and_emit( + _KEY_SHOW_PATH_FOR_DUPLICATES, + "_show_path_for_duplicates", + bool(value), + ) + + @property + def mark_modified(self) -> bool: + """Return whether modified requests show a dirty marker.""" + return self._mark_modified + + @mark_modified.setter + def mark_modified(self, value: bool) -> None: + """Persist dirty-indicator visibility.""" + self._set_and_emit(_KEY_MARK_MODIFIED, "_mark_modified", bool(value)) + + @property + def show_full_path_on_hover(self) -> bool: + """Return whether the tab tooltip shows the full request path.""" + return self._show_full_path_on_hover + + @show_full_path_on_hover.setter + def show_full_path_on_hover(self, value: bool) -> None: + """Persist the full-path tooltip preference.""" + self._set_and_emit( + _KEY_SHOW_FULL_PATH_ON_HOVER, + "_show_full_path_on_hover", + bool(value), + ) + + @property + def open_new_tabs_at_end(self) -> bool: + """Return whether new request tabs open at the end of the strip.""" + return self._open_new_tabs_at_end + + @open_new_tabs_at_end.setter + def open_new_tabs_at_end(self, value: bool) -> None: + """Persist the new-tab positioning preference.""" + self._set_and_emit(_KEY_OPEN_NEW_AT_END, "_open_new_tabs_at_end", bool(value)) + + @property + def enable_preview_tab(self) -> bool: + """Return whether preview tabs are enabled.""" + return self._enable_preview_tab + + @enable_preview_tab.setter + def enable_preview_tab(self, value: bool) -> None: + """Persist the preview-tab preference.""" + self._set_and_emit(_KEY_ENABLE_PREVIEW, "_enable_preview_tab", bool(value)) + + @property + def tab_limit(self) -> int: + """Return the maximum number of simultaneously open request tabs.""" + return self._tab_limit + + @tab_limit.setter + def tab_limit(self, value: int) -> None: + """Persist the configured request-tab limit.""" + self._set_and_emit(_KEY_TAB_LIMIT, "_tab_limit", _clamp_tab_limit(value)) + + @property + def tab_limit_policy(self) -> str: + """Return the overflow policy used when the tab limit is exceeded.""" + return self._tab_limit_policy + + @tab_limit_policy.setter + def tab_limit_policy(self, value: str) -> None: + """Persist the limit-overflow policy.""" + choice = value if value in LIMIT_POLICIES else LIMIT_CLOSE_UNUSED + self._set_and_emit(_KEY_TAB_LIMIT_POLICY, "_tab_limit_policy", choice) + + @property + def activate_on_close(self) -> str: + """Return the preferred tab-selection policy after closing the active tab.""" + return self._activate_on_close + + @activate_on_close.setter + def activate_on_close(self, value: str) -> None: + """Persist the close-activation policy.""" + choice = value if value in ACTIVATE_POLICIES else ACTIVATE_MRU + self._set_and_emit(_KEY_ACTIVATE_ON_CLOSE, "_activate_on_close", choice) + + @property + def wrap_mode(self) -> str: + """Return the visual layout mode used by the request-tab deck.""" + return self._wrap_mode + + @wrap_mode.setter + def wrap_mode(self, value: str) -> None: + """Persist the request-tab wrap-mode preference.""" + choice = value if value in WRAP_MODES else WRAP_MULTIPLE_ROWS + self._set_and_emit(_KEY_WRAP_MODE, "_wrap_mode", choice) diff --git a/tests/conftest.py b/tests/conftest.py index 5661c5e..0a5ee6f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -64,6 +64,14 @@ def _fresh_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): db_mod._SessionLocal = None +@pytest.fixture(autouse=True) +def _reset_tab_settings() -> None: + """Clear persisted tab settings so tests do not leak UI preferences.""" + settings = QSettings("Postmark", "Postmark") + settings.remove("tabs") + settings.sync() + + # ------------------------------------------------------------------ # Collection + request factory (convenience for UI & service tests) # ------------------------------------------------------------------ diff --git a/tests/ui/collections/test_collection_tree_actions.py b/tests/ui/collections/test_collection_tree_actions.py index 9cc32c7..91f38fb 100644 --- a/tests/ui/collections/test_collection_tree_actions.py +++ b/tests/ui/collections/test_collection_tree_actions.py @@ -12,10 +12,10 @@ class TestCollectionTreeDoubleClick: - """Tests for double-click to open request and folder.""" + """Tests for double-click — now a no-op.""" - def test_double_click_request_emits_open(self, qapp: QApplication, qtbot) -> None: - """Double-clicking a request item emits item_action_triggered.""" + def test_double_click_request_is_noop(self, qapp: QApplication, qtbot) -> None: + """Double-clicking a request item does not emit a signal.""" tree = CollectionTree() qtbot.addWidget(tree) @@ -33,10 +33,11 @@ def test_double_click_request_emits_open(self, qapp: QApplication, qtbot) -> Non folder = top_level_items(tree)[0] req_item = folder.child(0) - with qtbot.waitSignal(tree.item_action_triggered, timeout=1000) as blocker: - tree._on_item_double_clicked(req_item, 0) + emitted: list[list] = [] + tree.item_action_triggered.connect(lambda *args: emitted.append(list(args))) + tree._on_item_double_clicked(req_item, 0) - assert blocker.args == ["request", 10, "Open"] + assert emitted == [] def test_double_click_folder_does_not_emit_signal(self, qapp: QApplication, qtbot) -> None: """Double-clicking a folder does not emit item_action_triggered. @@ -103,8 +104,8 @@ def test_overview_action_emits_folder_open(self, qapp: QApplication, qtbot) -> N class TestCollectionTreeSingleClick: """Tests for single-click behaviour on tree items.""" - def test_single_click_folder_does_not_expand(self, qapp: QApplication, qtbot) -> None: - """Single-clicking a folder does not toggle expand.""" + def test_single_click_folder_toggles_expand(self, qapp: QApplication, qtbot) -> None: + """Single-clicking a folder toggles its expanded state.""" tree = CollectionTree() qtbot.addWidget(tree) @@ -122,15 +123,14 @@ def test_single_click_folder_does_not_expand(self, qapp: QApplication, qtbot) -> folder = top_level_items(tree)[0] folder.setExpanded(False) - emitted: list[list] = [] - tree.item_action_triggered.connect(lambda *args: emitted.append(list(args))) tree._on_item_clicked(folder, 0) + assert folder.isExpanded() + tree._on_item_clicked(folder, 0) assert not folder.isExpanded() - assert emitted == [] - def test_single_click_request_emits_preview(self, qapp: QApplication, qtbot) -> None: - """Single-clicking a request emits Preview action.""" + def test_single_click_request_emits_open(self, qapp: QApplication, qtbot) -> None: + """Single-clicking a request emits Open action.""" tree = CollectionTree() qtbot.addWidget(tree) @@ -151,7 +151,7 @@ def test_single_click_request_emits_preview(self, qapp: QApplication, qtbot) -> with qtbot.waitSignal(tree.item_action_triggered, timeout=1000) as blocker: tree._on_item_clicked(req_item, 0) - assert blocker.args == ["request", 10, "Preview"] + assert blocker.args == ["request", 10, "Open"] class TestCollectionTreeKeyboardShortcuts: diff --git a/tests/ui/dialogs/test_settings_dialog.py b/tests/ui/dialogs/test_settings_dialog.py index 9359e81..a156630 100644 --- a/tests/ui/dialogs/test_settings_dialog.py +++ b/tests/ui/dialogs/test_settings_dialog.py @@ -7,6 +7,12 @@ from PySide6.QtWidgets import QApplication from ui.dialogs.settings_dialog import SettingsDialog +from ui.styling.tab_settings_manager import ( + ACTIVATE_MRU, + LIMIT_CLOSE_UNUSED, + WRAP_SINGLE_ROW, + TabSettingsManager, +) from ui.styling.theme_manager import ( _APP, _ORG, @@ -24,6 +30,7 @@ def _clear_theme_settings() -> None: """Clear persisted theme QSettings before each test.""" settings = QSettings(_ORG, _APP) settings.remove("theme") + settings.remove("tabs") settings.sync() @@ -51,6 +58,13 @@ def test_appearance_category_exists(self, qapp: QApplication, qtbot) -> None: qtbot.addWidget(dialog) assert dialog._cat_list.item(0).text() == "Appearance" + def test_tabs_category_exists(self, qapp: QApplication, qtbot) -> None: + """The second category is 'Tabs'.""" + tm = ThemeManager(qapp) + dialog = SettingsDialog(tm) + qtbot.addWidget(dialog) + assert dialog._cat_list.item(1).text() == "Tabs" + class TestSettingsDialogCombos: """Tests for the combo box contents and defaults.""" @@ -156,3 +170,45 @@ def test_combo_reflects_theme_manager_state(self, qapp: QApplication, qtbot) -> assert dialog._style_combo.currentData() == STYLE_NATIVE assert dialog._scheme_combo.currentData() == SCHEME_DARK + + def test_apply_updates_tab_settings(self, qapp: QApplication, qtbot) -> None: + """Applying the Tabs page persists the tab preferences.""" + tm = ThemeManager(qapp) + tab_settings = TabSettingsManager(qapp) + dialog = SettingsDialog(tm, tab_settings) + qtbot.addWidget(dialog) + + dialog._small_labels_check.setChecked(False) + dialog._preview_tab_check.setChecked(False) + dialog._wrap_mode_combo.setCurrentIndex(1) + dialog._tab_limit_spin.setValue(42) + dialog._tab_limit_policy_combo.setCurrentIndex(1) + dialog._activate_on_close_combo.setCurrentIndex(2) + + dialog._on_apply() + + assert not tab_settings.small_labels + assert not tab_settings.enable_preview_tab + assert tab_settings.wrap_mode == WRAP_SINGLE_ROW + assert tab_settings.tab_limit == 42 + assert tab_settings.tab_limit_policy == LIMIT_CLOSE_UNUSED + assert tab_settings.activate_on_close == ACTIVATE_MRU + + def test_tabs_page_reflects_existing_settings(self, qapp: QApplication, qtbot) -> None: + """Opening the dialog reflects persisted tab settings.""" + tm = ThemeManager(qapp) + tab_settings = TabSettingsManager(qapp) + tab_settings.small_labels = False + tab_settings.show_path_for_duplicates = False + tab_settings.enable_preview_tab = False + tab_settings.wrap_mode = WRAP_SINGLE_ROW + tab_settings.tab_limit = 55 + + dialog = SettingsDialog(tm, tab_settings) + qtbot.addWidget(dialog) + + assert not dialog._small_labels_check.isChecked() + assert not dialog._show_path_duplicates_check.isChecked() + assert not dialog._preview_tab_check.isChecked() + assert dialog._wrap_mode_combo.currentData() == WRAP_SINGLE_ROW + assert dialog._tab_limit_spin.value() == 55 diff --git a/tests/ui/request/navigation/test_request_tab_bar.py b/tests/ui/request/navigation/test_request_tab_bar.py index 28c2989..d8790a0 100644 --- a/tests/ui/request/navigation/test_request_tab_bar.py +++ b/tests/ui/request/navigation/test_request_tab_bar.py @@ -7,6 +7,7 @@ from PySide6.QtWidgets import QApplication, QTabBar from ui.request.navigation.request_tab_bar import RequestTabBar +from ui.styling.tab_settings_manager import WRAP_SINGLE_ROW, TabSettingsManager class TestRequestTabBar: @@ -126,6 +127,118 @@ def test_remove_reindexes_labels(self, qapp: QApplication, qtbot) -> None: assert label_c is not None assert label_c._name == "C" + def test_duplicate_names_show_path_suffix_when_enabled(self, qapp: QApplication, qtbot) -> None: + """Duplicate request names show a compact path suffix.""" + settings = TabSettingsManager(qapp) + settings.show_path_for_duplicates = True + bar = RequestTabBar(settings) + qtbot.addWidget(bar) + + bar.add_request_tab("GET", "Reservation", path="API / Booking / Reservation") + bar.add_request_tab("POST", "Reservation", path="API / Billing / Reservation") + + label = bar.tab_label(0) + assert label is not None + assert "Booking" in label._name_label.text() + + def test_small_labels_setting_compacts_tab_height(self, qapp: QApplication, qtbot) -> None: + """Compact label mode reduces the tab-bar height.""" + settings = TabSettingsManager(qapp) + settings.small_labels = False + bar = RequestTabBar(settings) + qtbot.addWidget(bar) + standard_height = bar.height() + + settings.small_labels = True + + assert bar.height() < standard_height + + def test_narrow_width_wraps_tabs_to_multiple_rows(self, qapp: QApplication, qtbot) -> None: + """A narrow deck wraps tabs into additional top rows.""" + bar = RequestTabBar() + qtbot.addWidget(bar) + bar.resize(220, bar.height()) + bar.show() + + for name in ("First Request", "Second Request", "Third Request"): + bar.add_request_tab("GET", name) + + qapp.processEvents() + + assert bar.height() > 40 + assert bar.tabRect(2).top() > bar.tabRect(0).top() + + def test_move_tab_reorders_visual_indices(self, qapp: QApplication, qtbot) -> None: + """Moving a tab updates label order and emits the reorder signal.""" + bar = RequestTabBar() + qtbot.addWidget(bar) + bar.add_request_tab("GET", "A") + bar.add_request_tab("POST", "B") + bar.add_request_tab("PUT", "C") + + with qtbot.waitSignal(bar.tab_reordered, timeout=1000) as blocker: + bar.move_tab(2, 0) + + label_c = bar.tab_label(0) + label_a = bar.tab_label(1) + label_b = bar.tab_label(2) + + assert blocker.args == [2, 0] + assert label_c is not None + assert label_c._name == "C" + assert label_a is not None + assert label_a._name == "A" + assert label_b is not None + assert label_b._name == "B" + + def test_single_row_mode_keeps_tabs_on_one_row(self, qapp: QApplication, qtbot) -> None: + """Single-row mode compresses tabs instead of wrapping them.""" + settings = TabSettingsManager(qapp) + settings.wrap_mode = WRAP_SINGLE_ROW + bar = RequestTabBar(settings) + qtbot.addWidget(bar) + bar.resize(220, bar.height()) + bar.show() + + for name in ("First Request", "Second Request", "Third Request"): + bar.add_request_tab("GET", name) + + qapp.processEvents() + + assert bar.tabRect(0).top() == bar.tabRect(2).top() + + def test_arrow_keys_change_current_tab(self, qapp: QApplication, qtbot) -> None: + """Arrow keys move between tabs when the wrapped deck has focus.""" + bar = RequestTabBar() + qtbot.addWidget(bar) + bar.add_request_tab("GET", "One") + bar.add_request_tab("POST", "Two") + bar.add_request_tab("PUT", "Three") + bar.show() + bar.setCurrentIndex(1) + bar.setFocus() + + qtbot.keyClick(bar, Qt.Key.Key_Right) + assert bar.currentIndex() == 2 + + qtbot.keyClick(bar, Qt.Key.Key_Left) + assert bar.currentIndex() == 1 + + def test_add_tab_after_show_keeps_chip_visible(self, qapp: QApplication, qtbot) -> None: + """Tabs opened after the deck is already visible still show their chip widgets.""" + bar = RequestTabBar() + qtbot.addWidget(bar) + bar.resize(480, bar.height()) + bar.show() + + bar.add_request_tab("GET", "Visible After Show") + qapp.processEvents() + + label = bar.tab_label(0) + assert label is not None + assert label.isVisibleTo(bar) + assert not bar.tabRect(0).isEmpty() + class TestRequestTabBarCloseButton: """Tests that tab close buttons are visible and functional.""" diff --git a/tests/ui/test_main_window.py b/tests/ui/test_main_window.py index c5173e6..f2b6aeb 100644 --- a/tests/ui/test_main_window.py +++ b/tests/ui/test_main_window.py @@ -12,6 +12,7 @@ from ui.main_window import MainWindow from ui.request.request_editor import RequestEditorWidget from ui.request.response_viewer import ResponseViewerWidget +from ui.styling.tab_settings_manager import TabSettingsManager class TestMainWindow: @@ -157,6 +158,108 @@ def test_item_action_open_triggers_editor(self, qapp: QApplication, qtbot) -> No assert window.request_widget._url_input.text() == "http://c.com" + def test_preview_setting_can_disable_preview_tabs(self, qapp: QApplication, qtbot) -> None: + """When preview tabs are disabled, Preview opens a permanent tab.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req = svc.create_request(coll.id, "GET", "http://preview-off.com", "Preview Off") + + tab_settings = TabSettingsManager(qapp) + tab_settings.enable_preview_tab = False + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.item_action_triggered.emit("request", req.id, "Preview") + + assert window._tab_bar.count() == 1 + assert not window._tabs[0].is_preview + + def test_tab_limit_closes_least_recently_used_safe_tab( + self, + qapp: QApplication, + qtbot, + ) -> None: + """Opening past the tab limit closes the least-recently-used safe tab.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req1 = svc.create_request(coll.id, "GET", "http://one.com", "One") + req2 = svc.create_request(coll.id, "GET", "http://two.com", "Two") + req3 = svc.create_request(coll.id, "GET", "http://three.com", "Three") + + tab_settings = TabSettingsManager(qapp) + tab_settings.tab_limit = 2 + tab_settings.tab_limit_policy = "close_unused" + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window._open_request(req1.id, push_history=True) + window._open_request(req2.id, push_history=True) + window._open_request(req3.id, push_history=True) + + open_request_ids = {ctx.request_id for ctx in window._tabs.values()} + assert window._tab_bar.count() == 2 + assert req1.id not in open_request_ids + assert req2.id in open_request_ids + assert req3.id in open_request_ids + + def test_close_unchanged_protects_dirty_tabs(self, qapp: QApplication, qtbot) -> None: + """The close-unchanged policy skips tabs with unsaved edits.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req1 = svc.create_request(coll.id, "GET", "http://one.com", "One") + req2 = svc.create_request(coll.id, "GET", "http://two.com", "Two") + req3 = svc.create_request(coll.id, "GET", "http://three.com", "Three") + + tab_settings = TabSettingsManager(qapp) + tab_settings.tab_limit = 2 + tab_settings.tab_limit_policy = "close_unchanged" + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window._open_request(req1.id, push_history=True) + window._open_request(req2.id, push_history=True) + window._tab_bar.setCurrentIndex(0) + window._tabs[0].editor._set_dirty(True) + + window._open_request(req3.id, push_history=True) + + open_request_ids = {ctx.request_id for ctx in window._tabs.values()} + assert req1.id in open_request_ids + assert req2.id not in open_request_ids + assert req3.id in open_request_ids + + def test_close_unchanged_respects_manual_reorder(self, qapp: QApplication, qtbot) -> None: + """Manual tab reordering changes which unchanged tab is evicted first.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req1 = svc.create_request(coll.id, "GET", "http://one.com", "One") + req2 = svc.create_request(coll.id, "GET", "http://two.com", "Two") + req3 = svc.create_request(coll.id, "GET", "http://three.com", "Three") + req4 = svc.create_request(coll.id, "GET", "http://four.com", "Four") + + tab_settings = TabSettingsManager(qapp) + tab_settings.tab_limit = 3 + tab_settings.tab_limit_policy = "close_unchanged" + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window._open_request(req1.id, push_history=True) + window._open_request(req2.id, push_history=True) + window._open_request(req3.id, push_history=True) + + window._tab_bar.move_tab(1, 0) + window._open_request(req4.id, push_history=True) + + open_request_ids = {ctx.request_id for ctx in window._tabs.values()} + assert req2.id not in open_request_ids + assert req1.id in open_request_ids + assert req3.id in open_request_ids + assert req4.id in open_request_ids + class TestMainWindowSendRequest: """Tests for the HTTP send pipeline wiring.""" diff --git a/tests/ui/test_main_window_tabs_navigation.py b/tests/ui/test_main_window_tabs_navigation.py new file mode 100644 index 0000000..0fece60 --- /dev/null +++ b/tests/ui/test_main_window_tabs_navigation.py @@ -0,0 +1,56 @@ +"""MainWindow tests for wrapped-tab navigation shortcuts and search.""" + +from __future__ import annotations + +from unittest.mock import patch + +from PySide6.QtWidgets import QApplication + +from services.collection_service import CollectionService +from ui.main_window import MainWindow + + +class TestMainWindowTabNavigation: + """Tests for keyboard-style tab navigation at the MainWindow level.""" + + def test_next_and_previous_tab_actions_cycle_tabs(self, qapp: QApplication, qtbot) -> None: + """Next/previous tab actions delegate to the wrapped deck.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req1 = svc.create_request(coll.id, "GET", "http://one.com", "One") + req2 = svc.create_request(coll.id, "POST", "http://two.com", "Two") + req3 = svc.create_request(coll.id, "PUT", "http://three.com", "Three") + + window = MainWindow() + qtbot.addWidget(window) + + window._open_request(req1.id, push_history=True) + window._open_request(req2.id, push_history=True) + window._open_request(req3.id, push_history=True) + window._tab_bar.setCurrentIndex(0) + + window._next_tab_action.trigger() + assert window._tab_bar.currentIndex() == 1 + + window._previous_tab_action.trigger() + assert window._tab_bar.currentIndex() == 0 + + def test_search_tabs_action_selects_matching_tab(self, qapp: QApplication, qtbot) -> None: + """The tab search action activates the chosen open tab.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req1 = svc.create_request(coll.id, "GET", "http://one.com", "One") + req2 = svc.create_request(coll.id, "POST", "http://two.com", "Two") + + window = MainWindow() + qtbot.addWidget(window) + + window._open_request(req1.id, push_history=True) + window._open_request(req2.id, push_history=True) + window._tab_bar.setCurrentIndex(0) + + with patch("ui.main_window.window.QInputDialog.getItem", return_value=("POST Two", True)): + window._search_tabs_action.trigger() + + assert window._tab_bar.currentIndex() == 1 + assert window.request_widget._url_input.text() == "http://two.com" From 3bcaa13ee57adedb77b0c131a234a53ff8af26d6 Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Sun, 15 Mar 2026 03:48:06 +0200 Subject: [PATCH 2/6] update --- src/ui/collections/tree/collection_tree.py | 22 +++++-------------- src/ui/request/navigation/request_tabs/bar.py | 4 +++- .../request/navigation/request_tabs/labels.py | 13 +++-------- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/src/ui/collections/tree/collection_tree.py b/src/ui/collections/tree/collection_tree.py index 493b250..861f409 100644 --- a/src/ui/collections/tree/collection_tree.py +++ b/src/ui/collections/tree/collection_tree.py @@ -6,24 +6,14 @@ from typing import Any from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtWidgets import ( - QLabel, - QStackedWidget, - QTreeWidget, - QTreeWidgetItem, - QVBoxLayout, - QWidget, -) +from PySide6.QtWidgets import (QLabel, QStackedWidget, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget) from ui.collections.tree.collection_tree_delegate import CollectionTreeDelegate -from ui.collections.tree.constants import ( - EMPTY_COLLECTION_HTML, - PLACEHOLDER_MARKER, - ROLE_ITEM_ID, - ROLE_ITEM_TYPE, - ROLE_METHOD, - ROLE_PLACEHOLDER, -) +from ui.collections.tree.constants import (EMPTY_COLLECTION_HTML, + PLACEHOLDER_MARKER, ROLE_ITEM_ID, + ROLE_ITEM_TYPE, ROLE_METHOD, + ROLE_PLACEHOLDER) from ui.collections.tree.draggable_tree_widget import DraggableTreeWidget from ui.collections.tree.tree_actions import _TreeActionsMixin from ui.styling.icons import phi diff --git a/src/ui/request/navigation/request_tabs/bar.py b/src/ui/request/navigation/request_tabs/bar.py index 58527f5..b393a5f 100644 --- a/src/ui/request/navigation/request_tabs/bar.py +++ b/src/ui/request/navigation/request_tabs/bar.py @@ -6,7 +6,8 @@ from typing import TYPE_CHECKING from PySide6.QtCore import QPoint, QRect, QSize, Qt, Signal -from PySide6.QtGui import QContextMenuEvent, QKeyEvent, QMouseEvent, QResizeEvent, QWheelEvent +from PySide6.QtGui import (QContextMenuEvent, QKeyEvent, QMouseEvent, + QResizeEvent, QWheelEvent) from PySide6.QtWidgets import QMenu, QSizePolicy, QTabBar, QWidget from .labels import FolderTabLabel, TabLabel, layout_config @@ -611,3 +612,4 @@ def _show_context_menu(self, index: int, global_pos: QPoint) -> None: elif chosen == force_close_all_act: self.force_close_all_requested.emit() self.force_close_all_requested.emit() + self.force_close_all_requested.emit() diff --git a/src/ui/request/navigation/request_tabs/labels.py b/src/ui/request/navigation/request_tabs/labels.py index b0716be..0163ace 100644 --- a/src/ui/request/navigation/request_tabs/labels.py +++ b/src/ui/request/navigation/request_tabs/labels.py @@ -9,16 +9,9 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QWidget from ui.styling.icons import phi -from ui.styling.theme import ( - BADGE_BORDER_RADIUS, - BADGE_FONT_SIZE, - BADGE_HEIGHT, - BADGE_MIN_WIDTH, - COLOR_SENDING, - COLOR_WHITE, - method_color, - method_short_label, -) +from ui.styling.theme import (BADGE_BORDER_RADIUS, BADGE_FONT_SIZE, + BADGE_HEIGHT, BADGE_MIN_WIDTH, COLOR_SENDING, + COLOR_WHITE, method_color, method_short_label) _DIRTY_BULLET = "\u2022 " From f38fab12a7c2abaf93db79c52b61e1980ccdcdef Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Sun, 15 Mar 2026 10:40:09 +0200 Subject: [PATCH 3/6] update --- .github/copilot-instructions.md | 1 + .../instructions/architecture.instructions.md | 26 +- .github/instructions/testing.instructions.md | 1 + scripts/profile_startup.py | 190 ++++++ src/ui/collections/tree/collection_tree.py | 22 +- src/ui/main_window/draft_controller.py | 1 + src/ui/main_window/tab_controller.py | 367 ++++++++++- src/ui/main_window/variable_controller.py | 35 +- src/ui/main_window/window.py | 31 +- src/ui/request/auth/auth_mixin.py | 157 ++++- src/ui/request/navigation/request_tabs/bar.py | 12 + .../request/navigation/request_tabs/labels.py | 13 +- .../request/request_editor/editor_widget.py | 6 +- src/ui/styling/tab_settings_manager.py | 32 + .../navigation/test_request_tab_bar.py | 4 +- tests/ui/sidebar/test_snippet_panel.py | 20 + tests/ui/test_main_window_session.py | 607 ++++++++++++++++++ 17 files changed, 1422 insertions(+), 103 deletions(-) create mode 100644 scripts/profile_startup.py create mode 100644 tests/ui/test_main_window_session.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7545e3e..1af0b43 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -286,6 +286,7 @@ tests/ ├── test_main_window_tabs_navigation.py # Wrapped tab deck shortcuts + search tests ├── test_main_window_save.py # SaveButton + RequestSaveEndToEnd tests ├── test_main_window_draft.py # Draft tab open/save lifecycle tests + ├── test_main_window_session.py # Tab session persistence (save/restore) tests ├── styling/ # Theme and icon tests │ ├── test_theme_manager.py │ └── test_icons.py diff --git a/.github/instructions/architecture.instructions.md b/.github/instructions/architecture.instructions.md index f62238d..d092c1e 100644 --- a/.github/instructions/architecture.instructions.md +++ b/.github/instructions/architecture.instructions.md @@ -75,7 +75,10 @@ RequestEditorWidget ──_on_fetch_schema──► SchemaFetchWorker (QThread in `main.py` and passed to `MainWindow`. It persists request-tab behaviour (preview enablement, compact labels, duplicate-name path disambiguation, wrap mode, tab limit, and close-activation policy) - via QSettings. + via QSettings. It also stores the open-tab session (tab list + active + index) for restore-on-launch via `save_open_tabs()` / + `load_open_tabs()` / `clear_open_tabs()`. Session data is a JSON + string under QSettings key `tabs/session`. - `CollectionService` is instantiated as `self._svc = CollectionService()` in `CollectionWidget.__init__`, but **every method is `@staticmethod`**. Do not add instance state without updating every call site. @@ -305,9 +308,24 @@ into `%Y-%m-%d %H:%M` strings for the UI. `QTabBar`; it keeps a small compatibility API (`currentIndex()`, `setCurrentIndex()`, `count()`, `tabRect()`, `tabButton()`, `tabToolTip()`, `select_next_tab()`, `select_previous_tab()`, - `tab_search_text()`) so `MainWindow` and tests do not depend on Qt - tab-bar internals. `MainWindow` enforces the limit/promotion policies - when opening and closing tabs. + `tab_search_text()`, `tab_request_info()`) so `MainWindow` and tests + do not depend on Qt tab-bar internals. `MainWindow` enforces the + limit/promotion policies when opening and closing tabs. + **Session persistence:** `_TabControllerMixin._persist_open_tabs()` saves + the current tab list (type + DB id + method + name for requests) and + active index after every tab open/close/reorder and in `closeEvent`. + **Deferred tab materialisation:** `_restore_tabs()` restores tabs + lazily after `CollectionWidget.load_finished` fires. 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 + `_materialise_deferred_tab()`. Old-format entries (without + `method`/`name`) fall back to eager `_open_request()` for backward + compatibility. Deleted requests/collections are silently skipped. + Draft (unsaved) tabs are serialized with `type: "draft"` and an inline + snapshot of the editor state (`get_request_data()` + `draft_name`). + On restore, `_restore_draft()` calls `_open_draft_request()` and + replays the saved state into the editor. 9. **Manual tab reorder changes close-unchanged priority** — when the user drags tabs into a new visible order, `_TabControllerMixin._on_tab_reordered` rewrites `TabContext.opened_order` to match that order. The diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 105086c..5912971 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -137,6 +137,7 @@ tests/ ├── test_main_window_tabs_navigation.py # Wrapped tab deck shortcuts + search tests ├── test_main_window_save.py # SaveButton + RequestSaveEndToEnd tests ├── test_main_window_draft.py # Draft tab open/save lifecycle tests + ├── test_main_window_session.py # Tab session persistence (save/restore) tests ├── styling/ # Theme and icon tests │ ├── test_theme_manager.py │ └── test_icons.py diff --git a/scripts/profile_startup.py b/scripts/profile_startup.py new file mode 100644 index 0000000..17a906b --- /dev/null +++ b/scripts/profile_startup.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Diagnostic: profile Postmark startup CPU and memory costs. + +Measures each phase of startup end-to-end: +1. QApplication + theme/settings init +2. MainWindow construction (no tabs yet) +3. Collection tree load (background fetch simulation) +4. Session restore (_restore_tabs) with N request tabs + +Also reports per-widget memory costs and per-tab restore timing. + +Usage: + poetry run python scripts/profile_startup.py [NUM_TABS] +""" + +from __future__ import annotations + +import cProfile +import gc +import io +import os +import pstats +import resource +import sys +import time + +# Ensure src/ is on the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + + +def _rss_mb() -> float: + """Return current RSS in megabytes.""" + return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 + + +def _snapshot(label: str, start: float, mem_before: float) -> None: + mem_after = _rss_mb() + elapsed = time.perf_counter() - start + print(f" {label}: {elapsed * 1000:>8.1f} ms RSS: {mem_after:>6.1f} MB (+{mem_after - mem_before:>5.1f} MB)") + + +def main() -> None: + num_tabs = int(sys.argv[1]) if len(sys.argv) > 1 else 10 + print(f"=== Postmark Startup Diagnostic ({num_tabs} tabs) ===\n") + + # -- Phase 1: QApplication + Theme + TabSettings -------------------- + mem0 = _rss_mb() + t0 = time.perf_counter() + + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + + from ui.styling.icons import load_font + from ui.styling.tab_settings_manager import TabSettingsManager + from ui.styling.theme_manager import ThemeManager + + theme_manager = ThemeManager(app) + tab_settings_manager = TabSettingsManager(app) + load_font() + + _snapshot("Phase 1 — QApp + theme + font", t0, mem0) + + # -- Phase 2: init_db ----------------------------------------------- + mem1 = _rss_mb() + t1 = time.perf_counter() + + from database.database import init_db + + init_db() + + _snapshot("Phase 2 — init_db()", t1, mem1) + + # -- Seed test data -------------------------------------------------- + mem2 = _rss_mb() + t2 = time.perf_counter() + + from services.collection_service import CollectionService + + svc = CollectionService() + coll = svc.create_collection("DiagnosticCollection") + request_ids: list[int] = [] + for i in range(num_tabs): + req = svc.create_request( + coll.id, "GET", f"http://example.com/api/endpoint-{i}", f"Request {i}" + ) + request_ids.append(req.id) + + _snapshot(f"Phase 2b — seed {num_tabs} requests", t2, mem2) + + # Persist a session with N tabs + tab_settings_manager.save_open_tabs({ + "tabs": [ + {"type": "request", "id": rid, "method": "GET", "name": f"Request {i}"} + for i, rid in enumerate(request_ids) + ], + "active": 0, + }) + + # -- Phase 3: MainWindow construction -------------------------------- + mem3 = _rss_mb() + t3 = time.perf_counter() + + from ui.main_window import MainWindow + + window = MainWindow( + theme_manager=theme_manager, + tab_settings_manager=tab_settings_manager, + ) + + _snapshot("Phase 3 — MainWindow.__init__()", t3, mem3) + + # -- Phase 4: load_finished → _restore_tabs -------------------------- + # Profile this phase in detail + mem4 = _rss_mb() + t4 = time.perf_counter() + + profiler = cProfile.Profile() + profiler.enable() + + window.collection_widget.load_finished.emit() + + profiler.disable() + + _snapshot("Phase 4 — load_finished + _restore_tabs", t4, mem4) + + # -- Phase 5: show() ------------------------------------------------- + mem5 = _rss_mb() + t5 = time.perf_counter() + + window.show() + app.processEvents() + + _snapshot("Phase 5 — show() + processEvents", t5, mem5) + + # -- Summary ---------------------------------------------------------- + total_ms = (time.perf_counter() - t0) * 1000 + total_mem = _rss_mb() + print(f"\n TOTAL: {total_ms:>8.1f} ms RSS: {total_mem:>6.1f} MB") + + # -- Per-widget memory estimate -------------------------------------- + print("\n--- Per-Widget Memory ---") + gc.collect() + mem_before_editor = _rss_mb() + + from ui.request.request_editor import RequestEditorWidget + + editors = [] + for _ in range(5): + editors.append(RequestEditorWidget()) + gc.collect() + mem_after_editor = _rss_mb() + per_editor = (mem_after_editor - mem_before_editor) / 5 + print(f" RequestEditorWidget: ~{per_editor:.1f} MB each") + + from ui.request.response_viewer import ResponseViewerWidget + + viewers = [] + mem_before_viewer = _rss_mb() + for _ in range(5): + viewers.append(ResponseViewerWidget()) + gc.collect() + mem_after_viewer = _rss_mb() + per_viewer = (mem_after_viewer - mem_before_viewer) / 5 + print(f" ResponseViewerWidget: ~{per_viewer:.1f} MB each") + + print(f" Pair (editor+viewer): ~{per_editor + per_viewer:.1f} MB each") + print(f" Estimated {num_tabs} tab pairs: ~{(per_editor + per_viewer) * num_tabs:.1f} MB") + + # -- Top 30 profiled functions in Phase 4 ---------------------------- + print("\n--- Phase 4 cProfile Top 30 (cumulative) ---") + stream = io.StringIO() + stats = pstats.Stats(profiler, stream=stream) + stats.sort_stats("cumulative") + stats.print_stats(30) + print(stream.getvalue()) + + # -- Top 30 by total time ------------------------------------------- + print("--- Phase 4 cProfile Top 30 (tottime) ---") + stream2 = io.StringIO() + stats2 = pstats.Stats(profiler, stream=stream2) + stats2.sort_stats("tottime") + stats2.print_stats(30) + print(stream2.getvalue()) + + window.close() + + +if __name__ == "__main__": + main() diff --git a/src/ui/collections/tree/collection_tree.py b/src/ui/collections/tree/collection_tree.py index 861f409..493b250 100644 --- a/src/ui/collections/tree/collection_tree.py +++ b/src/ui/collections/tree/collection_tree.py @@ -6,14 +6,24 @@ from typing import Any from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtWidgets import (QLabel, QStackedWidget, QTreeWidget, - QTreeWidgetItem, QVBoxLayout, QWidget) +from PySide6.QtWidgets import ( + QLabel, + QStackedWidget, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) from ui.collections.tree.collection_tree_delegate import CollectionTreeDelegate -from ui.collections.tree.constants import (EMPTY_COLLECTION_HTML, - PLACEHOLDER_MARKER, ROLE_ITEM_ID, - ROLE_ITEM_TYPE, ROLE_METHOD, - ROLE_PLACEHOLDER) +from ui.collections.tree.constants import ( + EMPTY_COLLECTION_HTML, + PLACEHOLDER_MARKER, + ROLE_ITEM_ID, + ROLE_ITEM_TYPE, + ROLE_METHOD, + ROLE_PLACEHOLDER, +) from ui.collections.tree.draggable_tree_widget import DraggableTreeWidget from ui.collections.tree.tree_actions import _TreeActionsMixin from ui.styling.icons import phi diff --git a/src/ui/main_window/draft_controller.py b/src/ui/main_window/draft_controller.py index 15a3ccd..951017f 100644 --- a/src/ui/main_window/draft_controller.py +++ b/src/ui/main_window/draft_controller.py @@ -121,6 +121,7 @@ def _open_draft_request(self) -> None: self._tab_bar.setCurrentIndex(idx) self._on_tab_changed(idx) + self._persist_open_tabs() # type: ignore[attr-defined] # ------------------------------------------------------------------ # Save draft request → save-to-collection dialog diff --git a/src/ui/main_window/tab_controller.py b/src/ui/main_window/tab_controller.py index 3153cca..7727b56 100644 --- a/src/ui/main_window/tab_controller.py +++ b/src/ui/main_window/tab_controller.py @@ -60,6 +60,8 @@ class _TabControllerMixin: _tab_settings_manager: TabSettingsManager _tab_open_counter: int _tab_activation_counter: int + _restoring_session: bool + _deferred_tabs: dict[int, dict] def _on_send_request(self) -> None: ... def _on_save_request(self) -> None: ... @@ -94,6 +96,23 @@ def _open_request( if is_preview and not self._tab_settings_manager.enable_preview_tab: is_preview = False + # 1. Check if already open in a materialised tab — no DB needed + for idx, ctx in self._tabs.items(): + if ctx.request_id == request_id: + self._tab_bar.setCurrentIndex(idx) + # Promote preview -> permanent on explicit Open + if not is_preview and ctx.is_preview: + ctx.is_preview = False + self._tab_bar.update_tab(idx, is_preview=False) + return + + # 1b. Check if already open in a deferred (lazy) tab — no DB needed + for idx, info in self._deferred_tabs.items(): + if info.get("request_id") == request_id: + self._tab_bar.setCurrentIndex(idx) + return + + # 2. Fetch from database only when we actually need to create a tab request = CollectionService.get_request(request_id) if request is None: logger.warning("Request id=%s not found", request_id) @@ -115,17 +134,7 @@ def _open_request( "auth": request.auth, } - # 1. Check if already open in a tab - for idx, ctx in self._tabs.items(): - if ctx.request_id == request_id: - self._tab_bar.setCurrentIndex(idx) - # Promote preview -> permanent on explicit Open - if not is_preview and ctx.is_preview: - ctx.is_preview = False - self._tab_bar.update_tab(idx, is_preview=False) - return - - # 2. Replace current preview tab if one exists + # 3. Replace current preview tab if one exists current_idx = self._tab_bar.currentIndex() current_ctx = self._tabs.get(current_idx) if current_ctx is not None and current_ctx.is_preview: @@ -137,7 +146,7 @@ def _open_request( path=request_path, ) else: - # 3. Open a new tab + # 4. Open a new tab self._create_tab(request_id, data, is_preview=is_preview, path=request_path) if push_history: @@ -209,6 +218,7 @@ def _create_tab( self._tab_bar.setCurrentIndex(idx) # Ensure stacks are synced even if setCurrentIndex didn't emit self._on_tab_changed(idx) + self._persist_open_tabs() return idx def _replace_tab( @@ -242,6 +252,7 @@ def _replace_tab( is_preview=is_preview, is_dirty=False, ) + self._persist_open_tabs() def _request_full_path(self, request_id: int) -> str | None: """Return the full breadcrumb path for a request tab.""" @@ -268,6 +279,10 @@ def _shift_tabs_for_insert(self, index: int) -> None: (old_idx if old_idx < index else old_idx + 1): ctx for old_idx, ctx in self._tabs.items() } + self._deferred_tabs = { + (old_idx if old_idx < index else old_idx + 1): info + for old_idx, info in self._deferred_tabs.items() + } def _on_editor_dirty_changed(self, dirty: bool) -> None: """Sync dirty state from the emitting editor back into the tab metadata.""" @@ -283,6 +298,13 @@ def _on_editor_dirty_changed(self, dirty: bool) -> None: def _on_tab_changed(self, index: int) -> None: """Switch the stacked widgets when the active tab changes.""" + if getattr(self, "_restoring_session", False): + return + + # Materialise deferred (lazy-loaded) tab on first selection + if index in getattr(self, "_deferred_tabs", {}): + self._materialise_deferred_tab(index) + ctx = self._tabs.get(index) if ctx is not None: self._tab_activation_counter += 1 @@ -308,9 +330,14 @@ def _on_tab_changed(self, index: int) -> None: self._response_area.show() self._save_btn.setVisible(True) self._sync_save_btn(ctx.editor.is_dirty) - # Update breadcrumb + # Update breadcrumb (reuse cached crumbs from materialisation) if ctx.request_id is not None: - crumbs = CollectionService.get_request_breadcrumb(ctx.request_id) + cached = getattr(ctx, "_cached_crumbs", None) + if cached is not None: + crumbs = cached + del ctx._cached_crumbs # type: ignore[attr-defined] + else: + crumbs = CollectionService.get_request_breadcrumb(ctx.request_id) self._breadcrumb_bar.set_path(crumbs) elif ctx.draft_name is not None: # Draft tab — show editable single-segment breadcrumb @@ -346,11 +373,249 @@ def _sync_tree_selection(self, ctx: TabContext | None) -> None: elif ctx.request_id is not None: self.collection_widget.select_and_scroll_to(ctx.request_id, "request") + # ------------------------------------------------------------------ + # Session persistence + # ------------------------------------------------------------------ + def _persist_open_tabs(self) -> None: + """Save the current tab list to settings for session restore.""" + if getattr(self, "_restoring_session", False): + return + tabs_list: list[dict[str, object]] = [] + all_indices = sorted(set(self._tabs) | set(self._deferred_tabs)) + for idx in all_indices: + ctx = self._tabs.get(idx) + if ctx is not None: + if ctx.tab_type == "folder" and ctx.collection_id is not None: + tabs_list.append({"type": "folder", "id": ctx.collection_id}) + elif ctx.tab_type == "request" and ctx.request_id is not None: + method, name = self._tab_bar.tab_request_info(idx) + tabs_list.append( + { + "type": "request", + "id": ctx.request_id, + "method": method or ctx.editor.get_request_data().get("method", "GET"), + "name": name, + } + ) + elif ctx.tab_type == "request" and ctx.request_id is None: + # Draft (unsaved) tab — snapshot the editor state. + entry: dict[str, object] = { + "type": "draft", + "data": ctx.editor.get_request_data(), + } + if ctx.draft_name: + entry["draft_name"] = ctx.draft_name + tabs_list.append(entry) + else: + # Deferred (not-yet-materialised) tab + info = self._deferred_tabs.get(idx) + if info is not None: + tabs_list.append( + { + "type": "request", + "id": info["request_id"], + "method": info.get("method", "GET"), + "name": info.get("name", ""), + } + ) + data = { + "tabs": tabs_list, + "active": self._tab_bar.currentIndex(), + } + 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). + """ + data = self._tab_settings_manager.load_open_tabs() + if data is None: + 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 + 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) + 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) + + def _restore_request_deferred(self, entry: dict, request_id: int) -> None: + """Create a lightweight tab chip for a persisted request tab. + + If the session entry contains ``method`` and ``name`` (new format), + the chip is created without any database query. Otherwise we fall + back to eager loading via :meth:`_open_request`. + """ + method = entry.get("method") + name = entry.get("name") + if not isinstance(method, str) or not isinstance(name, str): + # Old format — fall back to eager loading + self._open_request(request_id, push_history=False, is_preview=False) + return + + # Block signals while adding the tab to avoid premature events. + self._tab_bar.blockSignals(True) + try: + idx = self._tab_bar.add_request_tab( + method, + name, + is_preview=False, + path=None, + ) + finally: + self._tab_bar.blockSignals(False) + + self._deferred_tabs[idx] = { + "request_id": request_id, + "method": method, + "name": name, + } + + def _materialise_deferred_tab(self, index: int) -> None: + """Build the editor and viewer for a deferred tab on first selection. + + Fetches the full request data from the database, creates the + editor/viewer pair, populates the editor, and wires signals. + If the database record was deleted between sessions, the tab + chip is silently removed. + + The breadcrumb crumbs are cached on the context as + ``_cached_crumbs`` so that :meth:`_on_tab_changed` can skip + the redundant ``get_request_breadcrumb`` call. + """ + info = self._deferred_tabs.pop(index) + request_id: int = info["request_id"] + + request = CollectionService.get_request(request_id) + if request is None: + logger.warning("Deferred request id=%s not found, removing tab", request_id) + self._tab_bar.remove_request_tab(index) + self._reindex_tabs_after_close(index) + return + + req_data: RequestLoadDict = { + "name": request.name, + "method": request.method, + "url": request.url, + "body": request.body, + "request_parameters": request.request_parameters, + "headers": request.headers, + "description": request.description, + "scripts": request.scripts, + "body_mode": request.body_mode, + "body_options": request.body_options, + "auth": request.auth, + } + + editor = RequestEditorWidget() + viewer = ResponseViewerWidget() + self._editor_stack.addWidget(editor) + self._response_stack.addWidget(viewer) + + ctx = TabContext( + request_id=request_id, + editor=editor, + response_viewer=viewer, + is_preview=False, + opened_order=self._next_tab_open_order(), + ) + self._tabs[index] = ctx + + editor.load_request(req_data, request_id=request_id) + editor.send_requested.connect(self._on_send_request) + 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()) + viewer.save_response_requested.connect(self._on_save_response) + viewer.save_availability_changed.connect(lambda _enabled: self._refresh_sidebar()) + + # Fetch breadcrumb once — reused by both the tab tooltip and + # _on_tab_changed (via _cached_crumbs) to avoid a duplicate query. + crumbs = CollectionService.get_request_breadcrumb(request_id) + request_path = ( + " / ".join(str(c.get("name", "")) for c in crumbs if c.get("name")) if crumbs else None + ) + ctx._cached_crumbs = crumbs # type: ignore[attr-defined] + + self._tab_bar.update_tab( + index, + method=req_data.get("method", "GET"), + name=req_data.get("name", ""), + path=request_path, + ) + + def _restore_draft(self, entry: dict) -> None: + """Restore a single draft tab from persisted session data.""" + draft_data = entry.get("data") + if not isinstance(draft_data, dict): + return + draft_name = entry.get("draft_name") + if isinstance(draft_name, str): + draft_data["name"] = draft_name + self._open_draft_request() # type: ignore[attr-defined] + # The new draft tab is now the active tab — overwrite its + # default blank state with the persisted editor snapshot. + idx = self._tab_bar.currentIndex() + ctx = self._tabs.get(idx) + if ctx is None or ctx.request_id is not None: + return + ctx.editor.load_request(cast(RequestLoadDict, draft_data), request_id=None) + ctx.editor._set_dirty(True) + if isinstance(draft_name, str): + ctx.draft_name = draft_name + method = draft_data.get("method", "GET") + self._tab_bar.update_tab(idx, method=method, name=draft_name) + # ------------------------------------------------------------------ # Tab close # ------------------------------------------------------------------ def _on_tab_close(self, index: int) -> None: """Close a tab and clean up its context.""" + # Handle deferred (lazy) tab close — no widgets to clean up + if index in self._deferred_tabs: + target_old_index = self._target_tab_after_close(index) + self._deferred_tabs.pop(index) + 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._persist_open_tabs() + return + target_old_index = self._target_tab_after_close(index) ctx = self._tabs.pop(index, None) if ctx is None: @@ -405,12 +670,8 @@ def _on_tab_close(self, index: int) -> None: self._tab_bar.remove_request_tab(index) - # Re-index remaining tabs - new_tabs: dict[int, TabContext] = {} - for old_idx, old_ctx in self._tabs.items(): - new_idx = old_idx if old_idx < index else old_idx - 1 - new_tabs[new_idx] = old_ctx - self._tabs = new_tabs + # Re-index remaining tabs (both materialised and deferred) + 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(): @@ -418,6 +679,17 @@ def _on_tab_close(self, index: int) -> None: self._on_tab_changed(target_new_index) else: self._on_tab_changed(self._tab_bar.currentIndex()) + self._persist_open_tabs() + + def _reindex_tabs_after_close(self, closed_index: int) -> None: + """Shift tab indices down after removing a tab at *closed_index*.""" + self._tabs = { + (idx if idx < closed_index else idx - 1): ctx for idx, ctx in self._tabs.items() + } + self._deferred_tabs = { + (idx if idx < closed_index else idx - 1): info + for idx, info in self._deferred_tabs.items() + } def _on_tab_double_click(self, index: int) -> None: """Promote a preview tab to a permanent tab.""" @@ -428,14 +700,15 @@ def _on_tab_double_click(self, index: int) -> None: def _target_tab_after_close(self, closing_index: int) -> int | None: """Return the preferred old-index tab to activate after closing one.""" - if not self._tabs: + all_indices = set(self._tabs) | set(self._deferred_tabs) + if not all_indices: return None current = self._tab_bar.currentIndex() if current != closing_index: return current - remaining = [idx for idx in self._tabs if idx != closing_index] + remaining = [idx for idx in all_indices if idx != closing_index] if not remaining: return None @@ -447,10 +720,12 @@ def _target_tab_after_close(self, closing_index: int) -> int | None: right = [idx for idx in remaining if idx > closing_index] return min(right) if right else max(remaining) + # MRU policy — deferred tabs have last_activated_order = 0 best_idx: int | None = None best_order = -1 for idx in remaining: - last_activated = self._tabs[idx].last_activated_order + ctx = self._tabs.get(idx) + last_activated = ctx.last_activated_order if ctx is not None else 0 if last_activated > best_order: best_idx = idx best_order = last_activated @@ -480,6 +755,10 @@ def _safe_limit_candidate_indices(self) -> list[int]: if ctx.is_sending or ctx.is_dirty: continue eligible.append(idx) + # Deferred tabs are always safe (not dirty, not sending) + for idx in self._deferred_tabs: + if idx != active: + eligible.append(idx) return eligible def _find_tab_limit_candidate(self) -> int | None: @@ -491,7 +770,10 @@ def _find_tab_limit_candidate(self) -> int | None: policy = self._tab_settings_manager.tab_limit_policy if policy == "close_unchanged": return min(candidates) - return min(candidates, key=lambda idx: self._tabs[idx].last_activated_order) + return min( + candidates, + key=lambda idx: self._tabs[idx].last_activated_order if idx in self._tabs else 0, + ) def _enforce_tab_limit_before_open(self) -> bool: """Close one safe tab when needed so a new tab can be opened.""" @@ -516,13 +798,25 @@ def _on_tab_reordered(self, from_index: int, to_index: int) -> None: """Keep logical tab state aligned with the visual tab order after drag-reorder.""" if from_index == to_index: return - ordered_indices = sorted(self._tabs) - ordered_contexts = [self._tabs[idx] for idx in ordered_indices] - moved = ordered_contexts.pop(from_index) - ordered_contexts.insert(to_index, moved) - for order, ctx in enumerate(ordered_contexts, start=1): - ctx.opened_order = order - self._tabs = {idx: ctx for idx, ctx in enumerate(ordered_contexts)} + # Build a unified ordered list of (index, state) where state is + # either a TabContext or a deferred info dict. + all_indices = sorted(set(self._tabs) | set(self._deferred_tabs)) + entries: list[TabContext | dict] = [ + self._tabs.get(idx) or self._deferred_tabs[idx] for idx in all_indices + ] + moved = entries.pop(from_index) + entries.insert(to_index, moved) + new_tabs: dict[int, TabContext] = {} + new_deferred: dict[int, dict] = {} + for order, item in enumerate(entries): + if isinstance(item, TabContext): + item.opened_order = order + 1 + new_tabs[order] = item + else: + new_deferred[order] = item + self._tabs = new_tabs + self._deferred_tabs = new_deferred + self._persist_open_tabs() # ------------------------------------------------------------------ # Folder tab management @@ -637,6 +931,7 @@ def _create_folder_tab( updated_at=updated_at, recent_requests=recent_requests, ) + self._persist_open_tabs() return idx def _on_folder_auto_save(self, data: dict) -> None: @@ -714,6 +1009,12 @@ def _sync_name_across_tabs(self, item_type: str, item_id: int, new_name: str) -> self._tab_bar.update_tab(idx, name=new_name, path=folder_path) if idx == self._tab_bar.currentIndex(): self._breadcrumb_bar.update_last_segment_text(new_name) + # Also update deferred tab chips (label only, no editor to refresh). + if item_type == "request": + for idx, info in self._deferred_tabs.items(): + if info["request_id"] == item_id: + info["name"] = new_name + self._tab_bar.update_tab(idx, name=new_name) # ------------------------------------------------------------------ # Navigation history @@ -742,13 +1043,13 @@ def _update_nav_actions(self) -> None: # ------------------------------------------------------------------ def _close_others_tabs(self, keep_index: int) -> None: """Close every tab except the one at *keep_index*.""" - indices = sorted(self._tabs.keys(), reverse=True) + indices = sorted(set(self._tabs) | set(self._deferred_tabs), reverse=True) for idx in indices: if idx != keep_index: self._on_tab_close(idx) def _close_all_tabs(self) -> None: """Close all open tabs.""" - indices = sorted(self._tabs.keys(), reverse=True) + indices = sorted(set(self._tabs) | set(self._deferred_tabs), reverse=True) for idx in indices: self._on_tab_close(idx) diff --git a/src/ui/main_window/variable_controller.py b/src/ui/main_window/variable_controller.py index ee643aa..bd35864 100644 --- a/src/ui/main_window/variable_controller.py +++ b/src/ui/main_window/variable_controller.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from services.environment_service import LocalOverride from ui.environments.environment_selector import EnvironmentSelector + from ui.request.navigation.request_tab_bar import RequestTabBar from ui.request.navigation.tab_manager import TabContext from ui.request.request_editor import RequestEditorWidget from ui.sidebar import RightSidebar @@ -37,6 +38,7 @@ class _VariableControllerMixin: _tabs: dict[int, TabContext] _right_sidebar: RightSidebar _sidebar_debounce: QTimer + _tab_bar: RequestTabBar def _current_tab_context(self) -> TabContext | None: ... @@ -159,7 +161,8 @@ def _on_add_unresolved_variable( request_id = ctx.request_id if ctx else None if request_id is None: return - from database.models.collections.collection_query_repository import get_request_by_id + from database.models.collections.collection_query_repository import \ + get_request_by_id req = get_request_by_id(request_id) if req is None: @@ -201,9 +204,8 @@ def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: variables = EnvironmentService.build_combined_variable_detail_map(env_id, None) # Merge folder-level variables from the collection chain if ctx.collection_id is not None: - from database.models.collections.collection_query_repository import ( - get_collection_variable_chain_detailed, - ) + from database.models.collections.collection_query_repository import \ + get_collection_variable_chain_detailed for key, (value, coll_id) in get_collection_variable_chain_detailed( ctx.collection_id @@ -239,6 +241,7 @@ def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: from services.collection_service import CollectionService auth = CollectionService.get_request_inherited_auth(ctx.request_id) + auth = self._substitute_auth(auth, flat_vars) self._right_sidebar.show_request_panels( variables, local_overrides=ctx.local_overrides, @@ -255,8 +258,7 @@ def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: if ctx.request_id is not None: from services.collection_service import CollectionService - request = CollectionService.get_request(ctx.request_id) - request_name = request.name if request is not None else None + _, request_name = self._tab_bar.tab_request_info(self._tab_bar.currentIndex()) saved_responses = CollectionService.get_saved_responses(ctx.request_id) self._right_sidebar.set_saved_response_context( request_id=ctx.request_id, @@ -290,14 +292,33 @@ def _refresh_sidebar_snippet(self) -> None: } flat_vars = {k: v["value"] for k, v in variables.items()} sub = EnvironmentService.substitute + auth = self._resolve_snippet_auth(data.get("auth"), ctx.request_id) + auth = self._substitute_auth(auth, flat_vars) self._right_sidebar.snippet_panel.update_request( method=editor._method_combo.currentText(), url=sub(editor._url_input.text().strip(), flat_vars), headers=sub(editor.get_headers_text() or "", flat_vars) or None, body=sub(data.get("body") or "", flat_vars) or None, - auth=self._resolve_snippet_auth(data.get("auth"), ctx.request_id), + auth=auth, ) + @staticmethod + def _substitute_auth(auth: dict | None, variables: dict[str, str]) -> dict | None: + """Substitute ``{{variable}}`` placeholders in auth entry values.""" + if not auth or not variables: + return auth + auth_type = auth.get("type", "noauth") + entries = auth.get(auth_type, []) + if not entries: + return auth + sub = EnvironmentService.substitute + substituted = dict(auth) + substituted[auth_type] = [ + {**e, "value": sub(str(e.get("value", "")), variables)} if isinstance(e, dict) else e + for e in entries + ] + return substituted + def _resolve_snippet_auth(self, auth: dict | None, request_id: int | None) -> dict | None: """Return the effective auth for snippet generation. diff --git a/src/ui/main_window/window.py b/src/ui/main_window/window.py index 477279b..63e0f93 100644 --- a/src/ui/main_window/window.py +++ b/src/ui/main_window/window.py @@ -6,26 +6,16 @@ from typing import TYPE_CHECKING, Any from PySide6.QtCore import QSize, Qt, QThread, QTimer -from PySide6.QtGui import QAction, QCloseEvent, QCursor, QGuiApplication, QKeySequence +from PySide6.QtGui import (QAction, QCloseEvent, QCursor, QGuiApplication, + QKeySequence) if TYPE_CHECKING: from ui.request.http_worker import HttpSendWorker -from PySide6.QtWidgets import ( - QApplication, - QHBoxLayout, - QInputDialog, - QMainWindow, - QMessageBox, - QPushButton, - QSizePolicy, - QSplitter, - QStackedWidget, - QTabWidget, - QToolBar, - QVBoxLayout, - QWidget, -) +from PySide6.QtWidgets import (QApplication, QHBoxLayout, QInputDialog, + QMainWindow, QMessageBox, QPushButton, + QSizePolicy, QSplitter, QStackedWidget, + QTabWidget, QToolBar, QVBoxLayout, QWidget) from services.collection_service import CollectionService from ui.collections.collection_widget import CollectionWidget @@ -85,9 +75,12 @@ def __init__( self._history_index: int = -1 self._tab_open_counter: int = 0 self._tab_activation_counter: int = 0 + self._restoring_session: bool = False # Per-tab state: tab-bar index -> TabContext self._tabs: dict[int, TabContext] = {} + # Deferred (not-yet-materialised) request tabs restored from session + self._deferred_tabs: dict[int, dict] = {} # Legacy single-send state (used when no tab is found) self._send_thread: QThread | None = None @@ -468,6 +461,9 @@ def _on_load_finished(self) -> None: for tb in self.findChildren(QToolBar): tb.show() + # Restore tabs from the previous session after collections are ready. + self._restore_tabs() + # ------------------------------------------------------------------ # Sidebar -> editor wiring # ------------------------------------------------------------------ @@ -645,7 +641,8 @@ def _sync_save_btn(self, dirty: bool) -> None: # Close event # ------------------------------------------------------------------ def closeEvent(self, event: QCloseEvent) -> None: - """Clean up all tabs and the console panel before closing.""" + """Persist session and clean up all tabs before closing.""" + self._persist_open_tabs() for ctx in self._tabs.values(): ctx.cancel_send() ctx.cleanup_thread() diff --git a/src/ui/request/auth/auth_mixin.py b/src/ui/request/auth/auth_mixin.py index 148fd6d..5f49c49 100644 --- a/src/ui/request/auth/auth_mixin.py +++ b/src/ui/request/auth/auth_mixin.py @@ -6,6 +6,12 @@ Mixed into both :class:`RequestEditorWidget` and :class:`FolderEditorWidget`. + +Auth pages are built **lazily** — only the inherit and no-auth pages +are constructed eagerly. Field-based pages (bearer, basic, apikey, …) +and the OAuth 2.0 page are materialised on first use (user selection, +data load, or test property access) to avoid ~1 s of widget-creation +overhead per editor instance at startup. """ from __future__ import annotations @@ -47,6 +53,7 @@ if TYPE_CHECKING: from PySide6.QtCore import QTimer + from services.environment_service import VariableDetail from ui.widgets.variable_line_edit import VariableLineEdit logger = logging.getLogger(__name__) @@ -74,6 +81,10 @@ def _build_auth_tab(self, auth_layout: QVBoxLayout) -> None: Left column: auth type selector + description text. Right column: stacked field pages for the selected auth type. + + Field-based pages are **not** built here — lightweight placeholder + widgets are inserted instead. The real page is materialised on + first use by :meth:`_ensure_auth_page`. """ columns = QHBoxLayout() columns.setSpacing(0) @@ -125,6 +136,9 @@ def _build_auth_tab(self, auth_layout: QVBoxLayout) -> None: # -- Right column: stacked field pages ---------------------------- self._auth_fields_stack = QStackedWidget() self._auth_widget_map: dict[str, dict[str, QWidget]] = {} + self._auth_built_pages: set[str] = set() + self._auth_placeholders: dict[str, QWidget] = {} + self._auth_variable_map: dict[str, VariableDetail] | None = None # 1. Inherit page (index 0) — just a preview label inherit_page, _ = build_inherit_page() @@ -133,32 +147,14 @@ def _build_auth_tab(self, auth_layout: QVBoxLayout) -> None: # 2. No Auth page (index 1) — empty placeholder self._auth_fields_stack.addWidget(build_noauth_page()) - # 3. Field-based pages (bearer, basic, apikey, digest, ...) - # OAuth 2.0 gets a custom page instead of FieldSpec. + # 3. Lightweight placeholders for field-based pages. + # Real widgets created on demand by _ensure_auth_page(). self._oauth2_page: OAuth2Page | None = None for auth_key in AUTH_FIELD_ORDER: - if auth_key == "oauth2": - oauth2_page = OAuth2Page(self._on_field_changed) - oauth2_page.get_token_requested.connect(self._on_get_oauth2_token) - self._oauth2_page = oauth2_page - self._auth_fields_stack.addWidget(oauth2_page) - self._auth_widget_map[auth_key] = {} - else: - specs = AUTH_FIELD_SPECS.get(auth_key, ()) - page, widgets = build_fields_page(specs, self._on_field_changed) - self._auth_fields_stack.addWidget(page) - self._auth_widget_map[auth_key] = widgets - - # Backward-compat attributes used by existing tests - bw = self._auth_widget_map.get("bearer", {}) - self._bearer_token_input = cast("VariableLineEdit", bw.get("token")) - baw = self._auth_widget_map.get("basic", {}) - self._basic_username_input = cast("VariableLineEdit", baw.get("username")) - self._basic_password_input = cast("VariableLineEdit", baw.get("password")) - akw = self._auth_widget_map.get("apikey", {}) - self._apikey_key_input = cast("VariableLineEdit", akw.get("key")) - self._apikey_value_input = cast("VariableLineEdit", akw.get("value")) - self._apikey_add_to_combo = cast(QComboBox, akw.get("in")) + placeholder = QWidget() + self._auth_fields_stack.addWidget(placeholder) + self._auth_placeholders[auth_key] = placeholder + self._auth_widget_map[auth_key] = {} # OAuth 2.0 worker state self._oauth2_thread: QThread | None = None @@ -166,10 +162,107 @@ def _build_auth_tab(self, auth_layout: QVBoxLayout) -> None: columns.addWidget(self._auth_fields_stack, 1) auth_layout.addLayout(columns, 1) + # -- Lazy page construction ---------------------------------------- + + def _ensure_auth_page(self, auth_key: str) -> None: + """Materialise the field page for *auth_key* if not yet built. + + Replaces the lightweight placeholder in the stacked widget with + the real form page (or :class:`OAuth2Page`). Applies the stored + variable map to any :class:`VariableLineEdit` children so that + ``{{variable}}`` highlighting works immediately. + """ + if auth_key in self._auth_built_pages: + return + if auth_key not in self._auth_placeholders: + return # inherit / noauth — always present + self._auth_built_pages.add(auth_key) + + placeholder = self._auth_placeholders.pop(auth_key) + idx = self._auth_fields_stack.indexOf(placeholder) + self._auth_fields_stack.removeWidget(placeholder) + placeholder.deleteLater() + + if auth_key == "oauth2": + page: QWidget = OAuth2Page(self._on_field_changed) + assert isinstance(page, OAuth2Page) + page.get_token_requested.connect(self._on_get_oauth2_token) + self._oauth2_page = page + self._auth_widget_map[auth_key] = {} + else: + specs = AUTH_FIELD_SPECS.get(auth_key, ()) + page, widgets = build_fields_page(specs, self._on_field_changed) + self._auth_widget_map[auth_key] = widgets + # Apply stored variable map to new VariableLineEdit widgets + if self._auth_variable_map is not None: + from ui.widgets.variable_line_edit import VariableLineEdit + + for widget in widgets.values(): + if isinstance(widget, VariableLineEdit): + widget.set_variable_map(self._auth_variable_map) + self._auth_fields_stack.insertWidget(idx, page) + + # -- Backward-compat lazy properties -------------------------------- + + @property + def _bearer_token_input(self) -> VariableLineEdit: + """Lazily build the bearer page and return the token input.""" + self._ensure_auth_page("bearer") + return cast("VariableLineEdit", self._auth_widget_map["bearer"]["token"]) + + @property + def _basic_username_input(self) -> VariableLineEdit: + """Lazily build the basic-auth page and return the username input.""" + self._ensure_auth_page("basic") + return cast("VariableLineEdit", self._auth_widget_map["basic"]["username"]) + + @property + def _basic_password_input(self) -> VariableLineEdit: + """Lazily build the basic-auth page and return the password input.""" + self._ensure_auth_page("basic") + return cast("VariableLineEdit", self._auth_widget_map["basic"]["password"]) + + @property + def _apikey_key_input(self) -> VariableLineEdit: + """Lazily build the API-key page and return the key input.""" + self._ensure_auth_page("apikey") + return cast("VariableLineEdit", self._auth_widget_map["apikey"]["key"]) + + @property + def _apikey_value_input(self) -> VariableLineEdit: + """Lazily build the API-key page and return the value input.""" + self._ensure_auth_page("apikey") + return cast("VariableLineEdit", self._auth_widget_map["apikey"]["value"]) + + @property + def _apikey_add_to_combo(self) -> QComboBox: + """Lazily build the API-key page and return the *Add to* combo.""" + self._ensure_auth_page("apikey") + return cast(QComboBox, self._auth_widget_map["apikey"]["in"]) + + # -- Auth variable map propagation --------------------------------- + + def _set_auth_variable_map(self, variables: dict[str, VariableDetail]) -> None: + """Store the variable map and propagate to built auth widgets. + + Pages that have not been materialised yet will receive the map + when :meth:`_ensure_auth_page` constructs them. + """ + from ui.widgets.variable_line_edit import VariableLineEdit + + self._auth_variable_map = variables + for auth_key in self._auth_built_pages: + for widget in self._auth_widget_map.get(auth_key, {}).values(): + if isinstance(widget, VariableLineEdit): + widget.set_variable_map(variables) + # -- Auth type switching ------------------------------------------- def _on_auth_type_changed(self, auth_type: str) -> None: """Switch the stacked page, update description, and track changes.""" + auth_key = AUTH_TYPE_KEYS.get(auth_type) + if auth_key: + self._ensure_auth_page(auth_key) idx = AUTH_PAGE_INDEX.get(auth_type, 0) self._auth_fields_stack.setCurrentIndex(idx) self._auth_description_label.setText(AUTH_TYPE_DESCRIPTIONS.get(auth_type, "")) @@ -264,6 +357,11 @@ def _load_auth(self, auth: dict | None) -> None: auth_type = auth.get("type", "inherit") display = AUTH_KEY_TO_DISPLAY.get(auth_type, "Inherit auth from parent") + + # Materialise the page before populating fields + if auth_type not in ("inherit", "noauth"): + self._ensure_auth_page(auth_type) + self._auth_type_combo.setCurrentText(display) entries = auth.get(auth_type, []) @@ -290,6 +388,7 @@ def _get_auth_data(self) -> dict | None: if not auth_key: return None + self._ensure_auth_page(auth_key) if auth_key == "oauth2" and self._oauth2_page is not None: entries = self._oauth2_page.get_entries() else: @@ -298,10 +397,14 @@ def _get_auth_data(self) -> dict | None: return {"type": auth_key, auth_key: entries} def _clear_auth(self) -> None: - """Reset the auth combo and all field widgets to defaults.""" + """Reset the auth combo and all field widgets to defaults. + + Only clears pages that have been materialised — unbuilt + placeholder pages have no widgets to reset. + """ self._auth_type_combo.setCurrentText("Inherit auth from parent") - for widgets in self._auth_widget_map.values(): - for widget in widgets.values(): + for auth_key in self._auth_built_pages: + for widget in self._auth_widget_map.get(auth_key, {}).values(): if isinstance(widget, QLineEdit): widget.clear() elif isinstance(widget, QComboBox): diff --git a/src/ui/request/navigation/request_tabs/bar.py b/src/ui/request/navigation/request_tabs/bar.py index b393a5f..9da1bdf 100644 --- a/src/ui/request/navigation/request_tabs/bar.py +++ b/src/ui/request/navigation/request_tabs/bar.py @@ -140,6 +140,17 @@ def tab_search_text(self, index: int) -> str: return f"{text} - {entry.path}" return text + def tab_request_info(self, index: int) -> tuple[str, str]: + """Return ``(method, name)`` for the request tab at *index*. + + Returns ``("", "")`` when the index is out of range or the tab + is not a request tab. + """ + entry = self._entry(index) + if entry is None or not isinstance(entry.label, TabLabel): + return ("", "") + return (entry.label._method, entry.label._name) + def select_next_tab(self) -> None: """Activate the next tab, wrapping at the end of the deck.""" self._cycle_current(1) @@ -613,3 +624,4 @@ def _show_context_menu(self, index: int, global_pos: QPoint) -> None: self.force_close_all_requested.emit() self.force_close_all_requested.emit() self.force_close_all_requested.emit() + self.force_close_all_requested.emit() diff --git a/src/ui/request/navigation/request_tabs/labels.py b/src/ui/request/navigation/request_tabs/labels.py index 0163ace..b0716be 100644 --- a/src/ui/request/navigation/request_tabs/labels.py +++ b/src/ui/request/navigation/request_tabs/labels.py @@ -9,9 +9,16 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QWidget from ui.styling.icons import phi -from ui.styling.theme import (BADGE_BORDER_RADIUS, BADGE_FONT_SIZE, - BADGE_HEIGHT, BADGE_MIN_WIDTH, COLOR_SENDING, - COLOR_WHITE, method_color, method_short_label) +from ui.styling.theme import ( + BADGE_BORDER_RADIUS, + BADGE_FONT_SIZE, + BADGE_HEIGHT, + BADGE_MIN_WIDTH, + COLOR_SENDING, + COLOR_WHITE, + method_color, + method_short_label, +) _DIRTY_BULLET = "\u2022 " diff --git a/src/ui/request/request_editor/editor_widget.py b/src/ui/request/request_editor/editor_widget.py index e6f984c..c95251e 100644 --- a/src/ui/request/request_editor/editor_widget.py +++ b/src/ui/request/request_editor/editor_widget.py @@ -232,11 +232,7 @@ def set_variable_map(self, variables: dict[str, VariableDetail]) -> None: self._loading = True try: self._url_input.set_variable_map(variables) - self._bearer_token_input.set_variable_map(variables) - self._basic_username_input.set_variable_map(variables) - self._basic_password_input.set_variable_map(variables) - self._apikey_key_input.set_variable_map(variables) - self._apikey_value_input.set_variable_map(variables) + self._set_auth_variable_map(variables) self._params_table.set_variable_map(variables) self._headers_table.set_variable_map(variables) self._body_form_table.set_variable_map(variables) diff --git a/src/ui/styling/tab_settings_manager.py b/src/ui/styling/tab_settings_manager.py index 475142b..eef633e 100644 --- a/src/ui/styling/tab_settings_manager.py +++ b/src/ui/styling/tab_settings_manager.py @@ -7,10 +7,15 @@ from __future__ import annotations +import json +import logging + from PySide6.QtCore import QObject, Signal from ui.styling.theme_manager import _APP, _ORG +logger = logging.getLogger(__name__) + _KEY_SMALL_LABELS = "tabs/small_labels" _KEY_SHOW_PATH_FOR_DUPLICATES = "tabs/show_path_for_duplicates" _KEY_MARK_MODIFIED = "tabs/mark_modified" @@ -21,6 +26,7 @@ _KEY_TAB_LIMIT_POLICY = "tabs/tab_limit_policy" _KEY_ACTIVATE_ON_CLOSE = "tabs/activate_on_close" _KEY_WRAP_MODE = "tabs/wrap_mode" +_KEY_SESSION = "tabs/session" LIMIT_CLOSE_UNCHANGED = "close_unchanged" LIMIT_CLOSE_UNUSED = "close_unused" @@ -226,3 +232,29 @@ def wrap_mode(self, value: str) -> None: """Persist the request-tab wrap-mode preference.""" choice = value if value in WRAP_MODES else WRAP_MULTIPLE_ROWS self._set_and_emit(_KEY_WRAP_MODE, "_wrap_mode", choice) + + # -- Session persistence ------------------------------------------- + + def save_open_tabs(self, data: dict) -> None: + """Persist the open-tab session state as JSON.""" + try: + self._settings.setValue(_KEY_SESSION, json.dumps(data)) + except (TypeError, ValueError): + logger.exception("Failed to serialize tab session") + + def load_open_tabs(self) -> dict | None: + """Load the persisted open-tab session state, or ``None``.""" + raw = self._settings.value(_KEY_SESSION) + if raw is None: + return None + try: + parsed = json.loads(str(raw)) + if isinstance(parsed, dict): + return parsed + except (json.JSONDecodeError, TypeError, ValueError): + logger.warning("Corrupt tab session data — ignoring") + return None + + def clear_open_tabs(self) -> None: + """Remove the persisted tab session.""" + self._settings.remove(_KEY_SESSION) diff --git a/tests/ui/request/navigation/test_request_tab_bar.py b/tests/ui/request/navigation/test_request_tab_bar.py index d8790a0..3527116 100644 --- a/tests/ui/request/navigation/test_request_tab_bar.py +++ b/tests/ui/request/navigation/test_request_tab_bar.py @@ -157,13 +157,15 @@ def test_narrow_width_wraps_tabs_to_multiple_rows(self, qapp: QApplication, qtbo """A narrow deck wraps tabs into additional top rows.""" bar = RequestTabBar() qtbot.addWidget(bar) - bar.resize(220, bar.height()) bar.show() + qapp.processEvents() + bar.setFixedWidth(220) for name in ("First Request", "Second Request", "Third Request"): bar.add_request_tab("GET", name) qapp.processEvents() + bar._relayout_tabs() assert bar.height() > 40 assert bar.tabRect(2).top() > bar.tabRect(0).top() diff --git a/tests/ui/sidebar/test_snippet_panel.py b/tests/ui/sidebar/test_snippet_panel.py index 177acfd..dd1670f 100644 --- a/tests/ui/sidebar/test_snippet_panel.py +++ b/tests/ui/sidebar/test_snippet_panel.py @@ -93,6 +93,26 @@ def test_snippet_with_bearer_auth(self, qapp: QApplication, qtbot) -> None: assert "Authorization" in text assert "Bearer tok123" in text + def test_snippet_with_substituted_auth_variable(self, qapp: QApplication, qtbot) -> None: + """Snippet resolves {{variable}} placeholders in auth values.""" + from ui.main_window.variable_controller import _VariableControllerMixin + + auth = { + "type": "bearer", + "bearer": [{"key": "token", "value": "{{api_key}}"}], + } + resolved = _VariableControllerMixin._substitute_auth(auth, {"api_key": "secret123"}) + panel = SnippetPanel() + qtbot.addWidget(panel) + panel.update_request( + method="GET", + url="https://api.example.com", + auth=resolved, + ) + text = panel._code_edit.toPlainText() + assert "Bearer secret123" in text + assert "{{api_key}}" not in text + def test_syntax_highlighting_language(self, qapp: QApplication, qtbot) -> None: """Snippet editor uses correct syntax language per combo selection.""" panel = SnippetPanel() diff --git a/tests/ui/test_main_window_session.py b/tests/ui/test_main_window_session.py new file mode 100644 index 0000000..66f4c8b --- /dev/null +++ b/tests/ui/test_main_window_session.py @@ -0,0 +1,607 @@ +"""Tests for tab session persistence (save on close, restore on launch).""" + +from __future__ import annotations + +import json + +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication + +from services.collection_service import CollectionService +from ui.main_window import MainWindow +from ui.styling.tab_settings_manager import TabSettingsManager + + +# ------------------------------------------------------------------ +# TabSettingsManager — unit tests for save/load/clear +# ------------------------------------------------------------------ +class TestTabSettingsManagerSession: + """Unit tests for the session persistence helpers on TabSettingsManager.""" + + def test_save_and_load_round_trip(self, qapp: QApplication) -> None: + """Saved session data survives a load round-trip.""" + mgr = TabSettingsManager(qapp) + payload = { + "tabs": [{"type": "request", "id": 1}], + "active": 0, + } + mgr.save_open_tabs(payload) + loaded = mgr.load_open_tabs() + assert loaded == payload + + def test_load_returns_none_when_empty(self, qapp: QApplication) -> None: + """load_open_tabs returns None when nothing has been saved.""" + mgr = TabSettingsManager(qapp) + assert mgr.load_open_tabs() is None + + def test_load_returns_none_for_corrupt_json(self, qapp: QApplication) -> None: + """Corrupt JSON in QSettings yields None instead of raising.""" + settings = QSettings("Postmark", "Postmark") + settings.setValue("tabs/session", "NOT VALID JSON {{{") + settings.sync() + + mgr = TabSettingsManager(qapp) + assert mgr.load_open_tabs() is None + + def test_load_returns_none_for_non_dict_json(self, qapp: QApplication) -> None: + """Valid JSON that is not a dict yields None.""" + settings = QSettings("Postmark", "Postmark") + settings.setValue("tabs/session", json.dumps([1, 2, 3])) + settings.sync() + + mgr = TabSettingsManager(qapp) + assert mgr.load_open_tabs() is None + + def test_clear_removes_saved_session(self, qapp: QApplication) -> None: + """clear_open_tabs removes the persisted session.""" + mgr = TabSettingsManager(qapp) + mgr.save_open_tabs({"tabs": [], "active": 0}) + mgr.clear_open_tabs() + assert mgr.load_open_tabs() is None + + +# ------------------------------------------------------------------ +# MainWindow — _persist_open_tabs +# ------------------------------------------------------------------ +class TestPersistOpenTabs: + """Tests for the _persist_open_tabs helper on MainWindow.""" + + def test_persist_records_open_request_tabs(self, qapp: QApplication, qtbot) -> None: + """_persist_open_tabs saves request tab IDs and active index.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req1 = svc.create_request(coll.id, "GET", "http://a.com", "A") + req2 = svc.create_request(coll.id, "POST", "http://b.com", "B") + + window = MainWindow() + qtbot.addWidget(window) + + window._open_request(req1.id, push_history=False) + window._open_request(req2.id, push_history=False) + window._tab_bar.setCurrentIndex(1) + + saved = window._tab_settings_manager.load_open_tabs() + assert saved is not None + assert saved["active"] == 1 + assert len(saved["tabs"]) == 2 + assert saved["tabs"][0] == {"type": "request", "id": req1.id, "method": "GET", "name": "A"} + assert saved["tabs"][1] == {"type": "request", "id": req2.id, "method": "POST", "name": "B"} + + def test_persist_records_folder_tabs(self, qapp: QApplication, qtbot) -> None: + """_persist_open_tabs saves folder tab collection IDs.""" + svc = CollectionService() + coll = svc.create_collection("FolderColl") + + window = MainWindow() + qtbot.addWidget(window) + + window._open_folder(coll.id) + + saved = window._tab_settings_manager.load_open_tabs() + assert saved is not None + assert len(saved["tabs"]) == 1 + assert saved["tabs"][0] == {"type": "folder", "id": coll.id} + + def test_persist_records_mixed_tabs(self, qapp: QApplication, qtbot) -> None: + """_persist_open_tabs handles a mix of request and folder tabs.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req = svc.create_request(coll.id, "GET", "http://x.com", "X") + + window = MainWindow() + qtbot.addWidget(window) + + window._open_request(req.id, push_history=False) + window._open_folder(coll.id) + + saved = window._tab_settings_manager.load_open_tabs() + assert saved is not None + assert len(saved["tabs"]) == 2 + types = [t["type"] for t in saved["tabs"]] + assert "request" in types + assert "folder" in types + + def test_persist_on_close_event(self, qapp: QApplication, qtbot) -> None: + """CloseEvent persists tabs before the window is destroyed.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req = svc.create_request(coll.id, "GET", "http://x.com", "X") + + window = MainWindow() + qtbot.addWidget(window) + + window._open_request(req.id, push_history=False) + + # Clear any previously persisted data to verify closeEvent writes it + window._tab_settings_manager.clear_open_tabs() + assert window._tab_settings_manager.load_open_tabs() is None + + window.close() + + saved = window._tab_settings_manager.load_open_tabs() + assert saved is not None + assert len(saved["tabs"]) == 1 + + +# ------------------------------------------------------------------ +# MainWindow — _restore_tabs +# ------------------------------------------------------------------ +class TestRestoreTabs: + """Tests for the _restore_tabs helper on MainWindow.""" + + def test_restore_opens_saved_request_tabs(self, qapp: QApplication, qtbot) -> None: + """_restore_tabs reopens request tabs from the persisted session.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req1 = svc.create_request(coll.id, "GET", "http://a.com", "A") + req2 = svc.create_request(coll.id, "POST", "http://b.com", "B") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [ + {"type": "request", "id": req1.id, "method": "GET", "name": "A"}, + {"type": "request", "id": req2.id, "method": "POST", "name": "B"}, + ], + "active": 1, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + # Simulate load_finished which triggers _restore_tabs + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 2 + assert window._tab_bar.currentIndex() == 1 + # Active tab is materialised on activation + assert window.request_widget._url_input.text() == "http://b.com" + + def test_restore_opens_folder_tabs(self, qapp: QApplication, qtbot) -> None: + """_restore_tabs reopens folder tabs from the persisted session.""" + svc = CollectionService() + coll = svc.create_collection("FolderColl") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [{"type": "folder", "id": coll.id}], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 1 + ctx = window._tabs[0] + assert ctx.tab_type == "folder" + assert ctx.collection_id == coll.id + + def test_restore_skips_deleted_request(self, qapp: QApplication, qtbot) -> None: + """Deleted requests are silently skipped during restore.""" + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [{"type": "request", "id": 999999}], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 0 + + def test_restore_skips_deleted_collection(self, qapp: QApplication, qtbot) -> None: + """Deleted collections are silently skipped during restore.""" + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [{"type": "folder", "id": 999999}], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 0 + + def test_restore_does_nothing_when_no_session(self, qapp: QApplication, qtbot) -> None: + """No session data means no tabs are restored.""" + window = MainWindow() + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 0 + + def test_restore_handles_mixed_valid_and_deleted(self, qapp: QApplication, qtbot) -> None: + """Valid tabs are restored while deleted ones are skipped.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req = svc.create_request(coll.id, "GET", "http://alive.com", "Alive") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [ + {"type": "request", "id": 999999}, + {"type": "request", "id": req.id}, + ], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 1 + assert window.request_widget._url_input.text() == "http://alive.com" + + def test_restore_clamps_active_index(self, qapp: QApplication, qtbot) -> None: + """Active index beyond restored count is clamped gracefully.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req = svc.create_request(coll.id, "GET", "http://x.com", "X") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [{"type": "request", "id": req.id}], + "active": 99, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + # Should not crash; tab 0 is the only option + assert window._tab_bar.count() == 1 + + def test_restore_ignores_unknown_tab_type(self, qapp: QApplication, qtbot) -> None: + """Unknown tab types in session data are silently skipped.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req = svc.create_request(coll.id, "GET", "http://x.com", "X") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [ + {"type": "alien", "id": 1}, + {"type": "request", "id": req.id}, + ], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 1 + + +# ------------------------------------------------------------------ +# Draft tab session persistence +# ------------------------------------------------------------------ +class TestDraftSessionPersistence: + """Tests for persisting and restoring unsaved draft tabs.""" + + def test_persist_records_draft_tabs(self, qapp: QApplication, qtbot) -> None: + """Draft tabs are serialized with editor state snapshot.""" + window = MainWindow() + qtbot.addWidget(window) + + window._open_draft_request() + window.request_widget._url_input.setText("http://draft.example.com") + window.request_widget._method_combo.setCurrentText("POST") + + # Force a fresh persist after edits + window._persist_open_tabs() + + saved = window._tab_settings_manager.load_open_tabs() + assert saved is not None + assert len(saved["tabs"]) == 1 + draft_entry = saved["tabs"][0] + assert draft_entry["type"] == "draft" + assert draft_entry["data"]["url"] == "http://draft.example.com" + assert draft_entry["data"]["method"] == "POST" + + def test_persist_includes_draft_name(self, qapp: QApplication, qtbot) -> None: + """Draft tabs include the user-set draft_name in session data.""" + window = MainWindow() + qtbot.addWidget(window) + + window._open_draft_request() + idx = window._tab_bar.currentIndex() + window._tabs[idx].draft_name = "My Custom Draft" + + window._persist_open_tabs() + saved = window._tab_settings_manager.load_open_tabs() + assert saved is not None + assert saved["tabs"][0]["draft_name"] == "My Custom Draft" + + def test_restore_reopens_draft_tab(self, qapp: QApplication, qtbot) -> None: + """Draft tabs are restored with their editor state.""" + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [ + { + "type": "draft", + "data": {"method": "PUT", "url": "http://draft.test"}, + } + ], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 1 + assert window.request_widget._url_input.text() == "http://draft.test" + assert window.request_widget._method_combo.currentText() == "PUT" + ctx = window._tabs[0] + assert ctx.request_id is None + + def test_restore_draft_with_custom_name(self, qapp: QApplication, qtbot) -> None: + """Restored draft tab uses the persisted draft_name.""" + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [ + { + "type": "draft", + "data": {"method": "GET", "url": ""}, + "draft_name": "Renamed Draft", + } + ], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 1 + ctx = window._tabs[0] + assert ctx.draft_name == "Renamed Draft" + + def test_restore_draft_skips_missing_data(self, qapp: QApplication, qtbot) -> None: + """Draft entry without a 'data' dict is silently skipped.""" + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [{"type": "draft"}], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 0 + + def test_persist_mixed_request_and_draft(self, qapp: QApplication, qtbot) -> None: + """Session with both persisted requests and drafts round-trips.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req = svc.create_request(coll.id, "GET", "http://saved.com", "Saved") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [ + {"type": "request", "id": req.id}, + { + "type": "draft", + "data": {"method": "DELETE", "url": "http://unsaved.com"}, + }, + ], + "active": 1, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 2 + # Tab 0: persisted request + assert window._tabs[0].request_id == req.id + # Tab 1: draft + assert window._tabs[1].request_id is None + assert window._tab_bar.currentIndex() == 1 + + +# ------------------------------------------------------------------ +# Deferred tab materialisation +# ------------------------------------------------------------------ +class TestDeferredTabRestore: + """Tests for deferred (lazy) tab restoration using the new session format.""" + + def test_deferred_tabs_create_chips_without_editor(self, qapp: QApplication, qtbot) -> None: + """New-format session entries create tab chips without materialising editors.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req1 = svc.create_request(coll.id, "GET", "http://a.com", "A") + req2 = svc.create_request(coll.id, "PUT", "http://b.com", "B") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [ + {"type": "request", "id": req1.id, "method": "GET", "name": "A"}, + {"type": "request", "id": req2.id, "method": "PUT", "name": "B"}, + ], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 2 + # Active tab (0) is materialised + assert 0 in window._tabs + assert window._tabs[0].request_id == req1.id + # Inactive tab (1) is deferred + assert 1 not in window._tabs + assert 1 in window._deferred_tabs + assert window._deferred_tabs[1]["request_id"] == req2.id + + def test_selecting_deferred_tab_materialises_it(self, qapp: QApplication, qtbot) -> None: + """Clicking a deferred tab creates its editor and viewer.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req1 = svc.create_request(coll.id, "GET", "http://a.com", "A") + req2 = svc.create_request(coll.id, "POST", "http://b.com", "B") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [ + {"type": "request", "id": req1.id, "method": "GET", "name": "A"}, + {"type": "request", "id": req2.id, "method": "POST", "name": "B"}, + ], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + window.collection_widget.load_finished.emit() + + # Switch to the deferred tab + window._tab_bar.setCurrentIndex(1) + + # Now it is materialised + assert 1 in window._tabs + assert 1 not in window._deferred_tabs + assert window._tabs[1].request_id == req2.id + assert window.request_widget._url_input.text() == "http://b.com" + + def test_deferred_deleted_request_removed_on_materialise( + self, qapp: QApplication, qtbot + ) -> None: + """Deferred tab for a deleted request silently disappears on selection.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req = svc.create_request(coll.id, "GET", "http://a.com", "A") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [ + {"type": "request", "id": req.id, "method": "GET", "name": "A"}, + {"type": "request", "id": 999999, "method": "DELETE", "name": "Gone"}, + ], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + window.collection_widget.load_finished.emit() + + assert window._tab_bar.count() == 2 + # Select the deferred tab pointing to a deleted request + window._tab_bar.setCurrentIndex(1) + + # The deleted tab should be removed + assert window._tab_bar.count() == 1 + + def test_old_format_falls_back_to_eager(self, qapp: QApplication, qtbot) -> None: + """Session entries without method/name use eager loading (backward compat).""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req = svc.create_request(coll.id, "GET", "http://old.com", "Old") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [{"type": "request", "id": req.id}], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + window.collection_widget.load_finished.emit() + + # Eagerly materialised — no deferred entry + assert 0 in window._tabs + assert 0 not in window._deferred_tabs + assert window._tabs[0].request_id == req.id + + def test_close_deferred_tab(self, qapp: QApplication, qtbot) -> None: + """Closing a deferred tab removes it without errors.""" + svc = CollectionService() + coll = svc.create_collection("Coll") + req1 = svc.create_request(coll.id, "GET", "http://a.com", "A") + req2 = svc.create_request(coll.id, "POST", "http://b.com", "B") + + tab_settings = TabSettingsManager(qapp) + tab_settings.save_open_tabs( + { + "tabs": [ + {"type": "request", "id": req1.id, "method": "GET", "name": "A"}, + {"type": "request", "id": req2.id, "method": "POST", "name": "B"}, + ], + "active": 0, + } + ) + + window = MainWindow(tab_settings_manager=tab_settings) + qtbot.addWidget(window) + window.collection_widget.load_finished.emit() + + # Close the deferred tab (index 1) + window._on_tab_close(1) + + assert window._tab_bar.count() == 1 + assert 1 not in window._deferred_tabs From c260862284cf0bcdf4f1f0a36de558ff36e7a9e0 Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Fri, 20 Mar 2026 21:29:26 +0200 Subject: [PATCH 4/6] feat: Implement tab change debouncing and flush mechanism for smoother navigation --- src/ui/main_window/draft_controller.py | 3 + src/ui/main_window/tab_controller.py | 94 ++++++++++++++----- src/ui/main_window/variable_controller.py | 8 +- src/ui/main_window/window.py | 8 ++ src/ui/request/navigation/request_tabs/bar.py | 27 +++++- .../navigation/test_request_tab_bar.py | 5 +- 6 files changed, 114 insertions(+), 31 deletions(-) diff --git a/src/ui/main_window/draft_controller.py b/src/ui/main_window/draft_controller.py index 951017f..c433b64 100644 --- a/src/ui/main_window/draft_controller.py +++ b/src/ui/main_window/draft_controller.py @@ -54,6 +54,7 @@ def _on_save_response(self, data: dict) -> 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: ... + def _flush_tab_change(self) -> None: ... def _enforce_tab_limit_before_open(self) -> bool: ... def _next_tab_open_order(self) -> int: ... def _next_tab_insert_index(self) -> int: ... @@ -121,6 +122,8 @@ def _open_draft_request(self) -> None: self._tab_bar.setCurrentIndex(idx) self._on_tab_changed(idx) + # Flush the debounced heavy work immediately for programmatic opens + self._flush_tab_change() self._persist_open_tabs() # type: ignore[attr-defined] # ------------------------------------------------------------------ diff --git a/src/ui/main_window/tab_controller.py b/src/ui/main_window/tab_controller.py index 7727b56..14c282f 100644 --- a/src/ui/main_window/tab_controller.py +++ b/src/ui/main_window/tab_controller.py @@ -9,6 +9,8 @@ import logging from typing import TYPE_CHECKING, Any, cast +from PySide6.QtCore import QTimer + from services.collection_service import CollectionService, RequestLoadDict from ui.request.navigation.tab_manager import TabContext from ui.request.request_editor import RequestEditorWidget @@ -62,6 +64,7 @@ class _TabControllerMixin: _tab_activation_counter: int _restoring_session: bool _deferred_tabs: dict[int, dict] + _tab_change_debounce: QTimer def _on_send_request(self) -> None: ... def _on_save_request(self) -> None: ... @@ -104,12 +107,14 @@ def _open_request( if not is_preview and ctx.is_preview: ctx.is_preview = False self._tab_bar.update_tab(idx, is_preview=False) + self._flush_tab_change() return # 1b. Check if already open in a deferred (lazy) tab — no DB needed for idx, info in self._deferred_tabs.items(): if info.get("request_id") == request_id: self._tab_bar.setCurrentIndex(idx) + self._flush_tab_change() return # 2. Fetch from database only when we actually need to create a tab @@ -218,6 +223,8 @@ def _create_tab( self._tab_bar.setCurrentIndex(idx) # Ensure stacks are synced even if setCurrentIndex didn't emit self._on_tab_changed(idx) + # Flush the debounced heavy work immediately for programmatic opens + self._flush_tab_change() self._persist_open_tabs() return idx @@ -297,7 +304,13 @@ def _on_editor_dirty_changed(self, dirty: bool) -> None: break def _on_tab_changed(self, index: int) -> None: - """Switch the stacked widgets when the active tab changes.""" + """Switch the stacked widgets when the active tab changes. + + Only the lightweight visual switch runs synchronously. Heavy + work (breadcrumb fetch, variable map, sidebar refresh, tree + sync) is debounced via ``_tab_change_debounce`` so that rapid + scrolling through tabs does not pile up expensive DB calls. + """ if getattr(self, "_restoring_session", False): return @@ -310,18 +323,12 @@ def _on_tab_changed(self, index: int) -> None: self._tab_activation_counter += 1 ctx.last_activated_order = self._tab_activation_counter + # -- Fast visual switch (no DB calls) -------------------------- if ctx is not None and ctx.tab_type == "folder": - # Folder tab -- show folder editor, hide response pane if ctx.folder_editor is not None: self._editor_stack.setCurrentWidget(ctx.folder_editor) self._response_area.hide() self._save_btn.setVisible(False) - # Update breadcrumb for folder - if ctx.collection_id is not None: - crumbs = CollectionService.get_collection_breadcrumb(ctx.collection_id) - self._breadcrumb_bar.set_path(crumbs) - else: - self._breadcrumb_bar.clear() elif ctx is not None: self._editor_stack.setCurrentWidget(ctx.editor) self._response_stack.setCurrentWidget(ctx.response_viewer) @@ -330,7 +337,40 @@ def _on_tab_changed(self, index: int) -> None: self._response_area.show() self._save_btn.setVisible(True) self._sync_save_btn(ctx.editor.is_dirty) - # Update breadcrumb (reuse cached crumbs from materialisation) + else: + self._editor_stack.setCurrentWidget(self._default_editor) + self._response_stack.setCurrentWidget(self._default_response_viewer) + self.request_widget = self._default_editor + self.response_widget = self._default_response_viewer + self._breadcrumb_bar.clear() + self._save_btn.setVisible(False) + + # -- Debounce the heavy work ----------------------------------- + self._tab_change_debounce.start() + + def _on_tab_change_settled(self, *, sync_tree: bool = False) -> None: + """Run expensive refresh work after tab changes settle. + + Invoked by the ``_tab_change_debounce`` single-shot timer so + that rapid scrolling coalesces into one heavy update. + + When *sync_tree* is ``False`` (the default for timer-fired + calls) the collection tree selection is **not** updated. This + avoids hijacking the tree scroll position while the user is + actively browsing the sidebar. Programmatic flushes pass + ``sync_tree=True`` to keep the tree in sync. + """ + index = self._tab_bar.currentIndex() + ctx = self._tabs.get(index) + + # -- Breadcrumb ------------------------------------------------ + if ctx is not None and ctx.tab_type == "folder": + if ctx.collection_id is not None: + crumbs = CollectionService.get_collection_breadcrumb(ctx.collection_id) + self._breadcrumb_bar.set_path(crumbs) + else: + self._breadcrumb_bar.clear() + elif ctx is not None: if ctx.request_id is not None: cached = getattr(ctx, "_cached_crumbs", None) if cached is not None: @@ -340,7 +380,6 @@ def _on_tab_changed(self, index: int) -> None: crumbs = CollectionService.get_request_breadcrumb(ctx.request_id) self._breadcrumb_bar.set_path(crumbs) elif ctx.draft_name is not None: - # Draft tab — show editable single-segment breadcrumb self._breadcrumb_bar.set_path( [{"name": ctx.draft_name, "type": "request", "id": 0}] ) @@ -348,21 +387,26 @@ def _on_tab_changed(self, index: int) -> None: self._breadcrumb_bar.clear() # Refresh variable map for highlighting and tooltips self._refresh_variable_map(ctx.editor, ctx.request_id, ctx.local_overrides) - else: - # No active tab -- fall back to the default widgets. - self._editor_stack.setCurrentWidget(self._default_editor) - self._response_stack.setCurrentWidget(self._default_response_viewer) - self.request_widget = self._default_editor - self.response_widget = self._default_response_viewer - self._breadcrumb_bar.clear() - self._save_btn.setVisible(False) - # Refresh right sidebar for the active tab using the same context - # that drove the stacked-widget switch. + # Refresh right sidebar for the active tab. self._refresh_sidebar(ctx) - # Sync collection tree selection to the active tab. - self._sync_tree_selection(ctx) + # Sync collection tree selection only on programmatic opens — + # not on timer-fired calls from tab-bar scrolling, to avoid + # hijacking the tree scroll while the user browses the sidebar. + if sync_tree: + self._sync_tree_selection(ctx) + + def _flush_tab_change(self) -> None: + """Immediately run pending debounced tab-change work. + + Called after programmatic tab opens and closes so the heavy + refresh happens synchronously instead of after the timer delay. + Tree selection sync is included since this is user-initiated. + """ + if self._tab_change_debounce.isActive(): + self._tab_change_debounce.stop() + self._on_tab_change_settled(sync_tree=True) def _sync_tree_selection(self, ctx: TabContext | None) -> None: """Highlight the active tab's item in the collection tree.""" @@ -467,6 +511,7 @@ def _restore_tabs(self) -> None: 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() def _restore_request_deferred(self, entry: dict, request_id: int) -> None: """Create a lightweight tab chip for a persisted request tab. @@ -613,6 +658,7 @@ def _on_tab_close(self, index: int) -> None: self._on_tab_changed(target_new_index) else: self._on_tab_changed(self._tab_bar.currentIndex()) + self._flush_tab_change() self._persist_open_tabs() return @@ -679,6 +725,7 @@ def _on_tab_close(self, index: int) -> None: self._on_tab_changed(target_new_index) else: self._on_tab_changed(self._tab_bar.currentIndex()) + self._flush_tab_change() self._persist_open_tabs() def _reindex_tabs_after_close(self, closed_index: int) -> None: @@ -855,6 +902,7 @@ def _open_folder(self, collection_id: int) -> None: for idx, ctx in self._tabs.items(): if ctx.tab_type == "folder" and ctx.collection_id == collection_id: self._tab_bar.setCurrentIndex(idx) + self._flush_tab_change() return # 2. Open a new folder tab @@ -922,6 +970,8 @@ def _create_folder_tab( # editor is visible even if load_collection raises. self._tab_bar.setCurrentIndex(idx) self._on_tab_changed(idx) + # Flush the debounced heavy work immediately for programmatic opens + self._flush_tab_change() folder_editor.load_collection( data, diff --git a/src/ui/main_window/variable_controller.py b/src/ui/main_window/variable_controller.py index bd35864..23e96a6 100644 --- a/src/ui/main_window/variable_controller.py +++ b/src/ui/main_window/variable_controller.py @@ -161,8 +161,7 @@ def _on_add_unresolved_variable( request_id = ctx.request_id if ctx else None if request_id is None: return - from database.models.collections.collection_query_repository import \ - get_request_by_id + from database.models.collections.collection_query_repository import get_request_by_id req = get_request_by_id(request_id) if req is None: @@ -204,8 +203,9 @@ def _refresh_sidebar(self, ctx: TabContext | None = None) -> None: variables = EnvironmentService.build_combined_variable_detail_map(env_id, None) # Merge folder-level variables from the collection chain if ctx.collection_id is not None: - from database.models.collections.collection_query_repository import \ - get_collection_variable_chain_detailed + from database.models.collections.collection_query_repository import ( + get_collection_variable_chain_detailed, + ) for key, (value, coll_id) in get_collection_variable_chain_detailed( ctx.collection_id diff --git a/src/ui/main_window/window.py b/src/ui/main_window/window.py index 63e0f93..24f0ce7 100644 --- a/src/ui/main_window/window.py +++ b/src/ui/main_window/window.py @@ -96,6 +96,13 @@ def __init__( self._sidebar_debounce.setSingleShot(True) self._sidebar_debounce.timeout.connect(self._refresh_sidebar_snippet) + # Debounce timer for heavy tab-change work (breadcrumb, sidebar, + # variable map, tree sync) so rapid scrolling stays smooth. + self._tab_change_debounce = QTimer(self) + self._tab_change_debounce.setSingleShot(True) + self._tab_change_debounce.setInterval(60) + self._tab_change_debounce.timeout.connect(self._on_tab_change_settled) + self._setup_ui() # Wire sidebar -> editor @@ -543,6 +550,7 @@ def _search_tabs(self) -> None: self._tab_bar.setCurrentIndex(target_index) self._on_tab_changed(target_index) + self._flush_tab_change() # ------------------------------------------------------------------ # Dialogs diff --git a/src/ui/request/navigation/request_tabs/bar.py b/src/ui/request/navigation/request_tabs/bar.py index 9da1bdf..2ac029a 100644 --- a/src/ui/request/navigation/request_tabs/bar.py +++ b/src/ui/request/navigation/request_tabs/bar.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from PySide6.QtCore import QPoint, QRect, QSize, Qt, Signal +from PySide6.QtCore import QPoint, QRect, QSize, Qt, QTimer, Signal from PySide6.QtGui import (QContextMenuEvent, QKeyEvent, QMouseEvent, QResizeEvent, QWheelEvent) from PySide6.QtWidgets import QMenu, QSizePolicy, QTabBar, QWidget @@ -23,6 +23,9 @@ _PADDING_Y = 4 _MIN_SINGLE_ROW_WIDTH = 1 +# How long (ms) to keep the mouse grab after the last wheel tick. +_SCROLL_GRAB_TIMEOUT = 200 + @dataclass class _TabEntry: @@ -60,8 +63,14 @@ def __init__( self._current_index = -1 self._tabs_closable = True self._hover_suppressed = False + self._scroll_grabbed = False self._layout_height = layout_config(False).tab_height + (_PADDING_Y * 2) + self._scroll_release_timer = QTimer(self) + self._scroll_release_timer.setSingleShot(True) + self._scroll_release_timer.setInterval(_SCROLL_GRAB_TIMEOUT) + self._scroll_release_timer.timeout.connect(self._release_scroll_grab) + self.setMouseTracking(True) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) @@ -368,6 +377,12 @@ def wheelEvent(self, event: QWheelEvent) -> None: """Cycle through tabs on mouse wheel scroll.""" if self.count() < 2: return + # Grab the mouse so the cursor can drift outside the narrow + # tab-bar strip without losing subsequent wheel events. + if not self._scroll_grabbed: + self._scroll_grabbed = True + self.grabMouse() + self._scroll_release_timer.start() for entry in self._entries: entry.button.suppress_hover() self._hover_suppressed = True @@ -378,9 +393,15 @@ def wheelEvent(self, event: QWheelEvent) -> None: self._cycle_current(1) event.accept() + def _release_scroll_grab(self) -> None: + """Release the mouse grab after the scroll idle timeout.""" + if self._scroll_grabbed: + self._scroll_grabbed = False + self.releaseMouse() + def mouseMoveEvent(self, event: QMouseEvent) -> None: """Restore hover visuals after a wheel-scroll suppression.""" - if self._hover_suppressed: + if self._hover_suppressed and not self._scroll_grabbed: self._hover_suppressed = False for entry in self._entries: entry.button.restore_hover() @@ -625,3 +646,5 @@ def _show_context_menu(self, index: int, global_pos: QPoint) -> None: self.force_close_all_requested.emit() self.force_close_all_requested.emit() self.force_close_all_requested.emit() + self.force_close_all_requested.emit() + self.force_close_all_requested.emit() diff --git a/tests/ui/request/navigation/test_request_tab_bar.py b/tests/ui/request/navigation/test_request_tab_bar.py index 3527116..0126b9d 100644 --- a/tests/ui/request/navigation/test_request_tab_bar.py +++ b/tests/ui/request/navigation/test_request_tab_bar.py @@ -157,13 +157,12 @@ def test_narrow_width_wraps_tabs_to_multiple_rows(self, qapp: QApplication, qtbo """A narrow deck wraps tabs into additional top rows.""" bar = RequestTabBar() qtbot.addWidget(bar) - bar.show() - qapp.processEvents() - bar.setFixedWidth(220) for name in ("First Request", "Second Request", "Third Request"): bar.add_request_tab("GET", name) + bar.setFixedWidth(220) + bar.show() qapp.processEvents() bar._relayout_tabs() From f87e121881e516ef20c44eeb19017104cae1721f Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Fri, 20 Mar 2026 22:39:55 +0200 Subject: [PATCH 5/6] feat: Add styling for sidebar panel area to maintain border consistency --- src/ui/styling/global_qss.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ui/styling/global_qss.py b/src/ui/styling/global_qss.py index 3baa36a..66471ea 100644 --- a/src/ui/styling/global_qss.py +++ b/src/ui/styling/global_qss.py @@ -631,6 +631,11 @@ def build_global_qss(p: ThemePalette) -> str: background: {p["bg"]}; border-right: 1px solid {p["border"]}; }} + /* Scroll area inside expanded sidebar must not override parent's right border */ + QWidget[objectName="sidebarPanelArea"] QScrollArea {{ + border: none; + border-right: 1px solid {p["border"]}; + }} QWidget[objectName="sidebarRail"] {{ background: {p["bg"]}; border-left: 1px solid {p["border"]}; From 2902b0ff6007e957375d811f71a198d1ed4241c7 Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Sat, 21 Mar 2026 22:22:58 +0200 Subject: [PATCH 6/6] update --- .github/instructions/pyside6.instructions.md | 14 + src/services/collection_service.py | 46 ++- src/ui/main_window/tab_controller.py | 13 +- src/ui/main_window/window.py | 22 +- src/ui/request/navigation/request_tabs/bar.py | 3 +- src/ui/sidebar/saved_responses/helpers.py | 51 +++ src/ui/sidebar/saved_responses/panel.py | 347 ++++++++---------- .../sidebar/saved_responses/search_filter.py | 155 +++++++- src/ui/sidebar/sidebar_widget.py | 19 +- src/ui/styling/global_qss.py | 12 +- .../ui/sidebar/test_saved_responses_panel.py | 101 ++++- tests/unit/services/test_service.py | 41 +++ 12 files changed, 603 insertions(+), 221 deletions(-) diff --git a/.github/instructions/pyside6.instructions.md b/.github/instructions/pyside6.instructions.md index a26864f..b0c67ec 100644 --- a/.github/instructions/pyside6.instructions.md +++ b/.github/instructions/pyside6.instructions.md @@ -220,6 +220,20 @@ standard object names: | `sidebarSourceDot` | `QLabel` | Colour-coded variable source dot | | `sidebarSeparator` | `QFrame` | Separator line in sidebar panels | +### QTabBar overflow scroll buttons + +When a `QTabWidget` has more tabs than fit, Qt shows left/right +`QToolButton` scroll arrows inside the `QTabBar`. These are styled +globally in `global_qss.py` with: + +- `background: input_bg` +- `border: 1px solid border` (sharp corners, `border-radius: 0`) +- `border-color: accent` on hover + +Do **not** override or remove the default platform arrows. Do **not** +add `border-radius`, `bg_alt` hover fills, or `image: none` rules. +The global rule is unscoped — it applies to every `QTabBar` in the app. + ### When inline setStyleSheet() is still acceptable Only use `setStyleSheet()` for **dynamic per-instance** styling that diff --git a/src/services/collection_service.py b/src/services/collection_service.py index fd860b4..5391226 100644 --- a/src/services/collection_service.py +++ b/src/services/collection_service.py @@ -137,12 +137,56 @@ def _normalize_header_list(headers: Any) -> list[dict[str, Any]] | None: return normalized or None +def _to_postman_request(editor_dict: dict[str, Any]) -> dict[str, Any]: + """Convert an editor-format request dict to a Postman-style request object. + + The editor dict (from ``RequestEditorWidget.get_request_data()``) has keys + like ``body_mode``, ``headers``, ``request_parameters``. This maps them to + the Postman shape: ``method``, ``header``, ``body``, ``url``. + """ + result: dict[str, Any] = {} + result["method"] = editor_dict.get("method", "GET") + + # -- header -------------------------------------------------------- + raw_headers = editor_dict.get("headers") + result["header"] = _normalize_header_list(raw_headers) or [] + + # -- body ---------------------------------------------------------- + body_mode = editor_dict.get("body_mode") or "none" + body_text = editor_dict.get("body") + if body_mode != "none" and body_text: + body_obj: dict[str, Any] = {"mode": body_mode} + if body_mode == "raw": + body_obj["raw"] = body_text + else: + body_obj[body_mode] = body_text + body_options = editor_dict.get("body_options") + if body_options: + body_obj["options"] = body_options + result["body"] = body_obj + + # -- url ----------------------------------------------------------- + url_str = editor_dict.get("url") or "" + result["url"] = {"raw": url_str} + + return result + + def _normalize_request_snapshot(original_request: Any) -> dict[str, Any] | None: - """Normalize saved original-request snapshots for read-only UI rendering.""" + """Normalize saved original-request snapshots for read-only UI rendering. + + Editor-format dicts (containing ``body_mode``) are converted to the + Postman request shape so all snapshots share one consistent contract. + """ if not isinstance(original_request, Mapping): return None normalized = dict(original_request) + + # Auto-convert editor-format dicts to Postman shape on save. + if "body_mode" in normalized: + return _to_postman_request(normalized) + if "headers" in normalized: normalized["headers"] = _normalize_header_list(normalized.get("headers")) return normalized diff --git a/src/ui/main_window/tab_controller.py b/src/ui/main_window/tab_controller.py index 14c282f..f588e69 100644 --- a/src/ui/main_window/tab_controller.py +++ b/src/ui/main_window/tab_controller.py @@ -23,6 +23,7 @@ from ui.collections.collection_widget import CollectionWidget from ui.request.navigation.breadcrumb_bar import BreadcrumbBar from ui.request.navigation.request_tab_bar import RequestTabBar + from ui.sidebar.sidebar_widget import RightSidebar from ui.styling.tab_settings_manager import TabSettingsManager logger = logging.getLogger(__name__) @@ -65,6 +66,7 @@ class _TabControllerMixin: _restoring_session: bool _deferred_tabs: dict[int, dict] _tab_change_debounce: QTimer + _right_sidebar: RightSidebar def _on_send_request(self) -> None: ... def _on_save_request(self) -> None: ... @@ -462,9 +464,11 @@ def _persist_open_tabs(self) -> None: "name": info.get("name", ""), } ) - data = { + data: dict[str, object] = { "tabs": tabs_list, "active": self._tab_bar.currentIndex(), + "sidebar_panel": self._right_sidebar.active_panel, + "sidebar_width": self._right_sidebar.flyout_width, } self._tab_settings_manager.save_open_tabs(data) @@ -513,6 +517,13 @@ def _restore_tabs(self) -> None: self._on_tab_changed(active) self._flush_tab_change() + 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) + def _restore_request_deferred(self, entry: dict, request_id: int) -> None: """Create a lightweight tab chip for a persisted request tab. diff --git a/src/ui/main_window/window.py b/src/ui/main_window/window.py index 24f0ce7..eed4d69 100644 --- a/src/ui/main_window/window.py +++ b/src/ui/main_window/window.py @@ -6,16 +6,26 @@ from typing import TYPE_CHECKING, Any from PySide6.QtCore import QSize, Qt, QThread, QTimer -from PySide6.QtGui import (QAction, QCloseEvent, QCursor, QGuiApplication, - QKeySequence) +from PySide6.QtGui import QAction, QCloseEvent, QCursor, QGuiApplication, QKeySequence if TYPE_CHECKING: from ui.request.http_worker import HttpSendWorker -from PySide6.QtWidgets import (QApplication, QHBoxLayout, QInputDialog, - QMainWindow, QMessageBox, QPushButton, - QSizePolicy, QSplitter, QStackedWidget, - QTabWidget, QToolBar, QVBoxLayout, QWidget) +from PySide6.QtWidgets import ( + QApplication, + QHBoxLayout, + QInputDialog, + QMainWindow, + QMessageBox, + QPushButton, + QSizePolicy, + QSplitter, + QStackedWidget, + QTabWidget, + QToolBar, + QVBoxLayout, + QWidget, +) from services.collection_service import CollectionService from ui.collections.collection_widget import CollectionWidget diff --git a/src/ui/request/navigation/request_tabs/bar.py b/src/ui/request/navigation/request_tabs/bar.py index 2ac029a..7acdbf0 100644 --- a/src/ui/request/navigation/request_tabs/bar.py +++ b/src/ui/request/navigation/request_tabs/bar.py @@ -6,8 +6,7 @@ from typing import TYPE_CHECKING from PySide6.QtCore import QPoint, QRect, QSize, Qt, QTimer, Signal -from PySide6.QtGui import (QContextMenuEvent, QKeyEvent, QMouseEvent, - QResizeEvent, QWheelEvent) +from PySide6.QtGui import QContextMenuEvent, QKeyEvent, QMouseEvent, QResizeEvent, QWheelEvent from PySide6.QtWidgets import QMenu, QSizePolicy, QTabBar, QWidget from .labels import FolderTabLabel, TabLabel, layout_config diff --git a/src/ui/sidebar/saved_responses/helpers.py b/src/ui/sidebar/saved_responses/helpers.py index 9048bcc..cb93a25 100644 --- a/src/ui/sidebar/saved_responses/helpers.py +++ b/src/ui/sidebar/saved_responses/helpers.py @@ -106,3 +106,54 @@ def format_headers(headers: Any) -> str: for header in headers if isinstance(header, dict) ) + + +def extract_snapshot_url(snapshot: Mapping[str, Any] | None) -> str: + """Extract the raw URL string from a Postman or legacy request snapshot.""" + if not snapshot: + return "" + url = snapshot.get("url") + if isinstance(url, Mapping): + return str(url.get("raw", "")) + if isinstance(url, str): + return url + return "" + + +def extract_snapshot_method(snapshot: Mapping[str, Any] | None) -> str: + """Extract the HTTP method from a request snapshot.""" + if not snapshot: + return "" + return str(snapshot.get("method", "")) + + +def extract_snapshot_body(snapshot: Mapping[str, Any] | None) -> tuple[str, str]: + """Extract the body text and language hint from a request snapshot. + + Returns a ``(body_text, language)`` tuple. For Postman-format + snapshots the body is inside ``body.raw`` (or the mode-specific key). + """ + if not snapshot: + return "", "text" + body = snapshot.get("body") + if isinstance(body, Mapping): + mode = body.get("mode", "raw") + raw = body.get(mode) or body.get("raw") or "" + lang = "text" + options = body.get("options") + if isinstance(options, Mapping): + raw_opts = options.get("raw") + if isinstance(raw_opts, Mapping): + lang = str(raw_opts.get("language", "text")) + return str(raw), lang + if isinstance(body, str): + return body, "text" + return "", "text" + + +def extract_snapshot_headers(snapshot: Mapping[str, Any] | None) -> str: + """Extract request headers text from a Postman or legacy snapshot.""" + if not snapshot: + return "" + headers = snapshot.get("header") or snapshot.get("headers") + return format_headers(headers) diff --git a/src/ui/sidebar/saved_responses/panel.py b/src/ui/sidebar/saved_responses/panel.py index e3c7d8e..4368a2a 100644 --- a/src/ui/sidebar/saved_responses/panel.py +++ b/src/ui/sidebar/saved_responses/panel.py @@ -5,13 +5,11 @@ from typing import TYPE_CHECKING, Any from PySide6.QtCore import Qt, Signal, SignalInstance -from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( QApplication, QComboBox, QHBoxLayout, QLabel, - QLineEdit, QListWidget, QListWidgetItem, QPushButton, @@ -30,14 +28,17 @@ from ui.sidebar.saved_responses.helpers import ( build_row_meta, detect_body_language, + extract_snapshot_body, + extract_snapshot_headers, + extract_snapshot_method, + extract_snapshot_url, format_body_size, format_code_text, format_headers, - format_json_text, ) from ui.sidebar.saved_responses.search_filter import _PanelSearchFilterMixin from ui.styling.icons import phi -from ui.styling.theme import status_color +from ui.styling.theme import method_color, status_color from ui.widgets.code_editor import CodeEditorWidget if TYPE_CHECKING: @@ -65,8 +66,11 @@ def __init__(self, parent: QWidget | None = 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._snapshot_view_mode: str = "Pretty" + self._req_body_view_mode: str = "Pretty" root = QVBoxLayout(self) root.setContentsMargins(8, 4, 8, 8) @@ -155,13 +159,40 @@ def __init__(self, parent: QWidget | None = None) -> None: detail_layout.addLayout(detail_header) + # -- Request info row (method badge + URL) ------------------------- + 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_snapshot_tab() + self._build_request_headers_tab() + self._build_request_body_tab() self._content_splitter.addWidget(detail_host) self._content_splitter.setSizes([180, 280]) @@ -170,161 +201,69 @@ def __init__(self, parent: QWidget | None = None) -> None: # -- Tab construction helpers -------------------------------------- - def _build_body_tab(self) -> None: - """Construct the Body tab with format combo, filter, search, and editor.""" - body_tab = QWidget() - body_layout = QVBoxLayout(body_tab) - body_layout.setContentsMargins(0, 4, 0, 0) - body_layout.setSpacing(6) - body_toolbar = QHBoxLayout() - body_toolbar.setContentsMargins(0, 0, 0, 0) - body_toolbar.setSpacing(6) - self._body_view_combo = QComboBox() - self._body_view_combo.addItems(["Pretty", "Raw"]) - self._body_view_combo.setFixedWidth(90) - self._body_view_combo.currentTextChanged.connect(self._refresh_body_view) - body_toolbar.addWidget(self._body_view_combo) - body_toolbar.addStretch() - self._body_edit = CodeEditorWidget(read_only=True) - self._body_copy_btn = self._make_copy_btn(lambda: self._copy_editor(self._body_edit)) - - self._body_filter_btn = QPushButton() - self._body_filter_btn.setIcon(phi("funnel")) - self._body_filter_btn.setToolTip("Filter response (JSONPath / XPath)") - self._body_filter_btn.setObjectName("iconButton") - self._body_filter_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self._body_filter_btn.setCheckable(True) - self._body_filter_btn.setFixedSize(28, 28) - self._body_filter_btn.clicked.connect(self._toggle_filter) - body_toolbar.addWidget(self._body_filter_btn) - - self._body_search_btn = QPushButton() - self._body_search_btn.setIcon(phi("magnifying-glass")) - find_hint = QKeySequence(QKeySequence.StandardKey.Find).toString( - QKeySequence.SequenceFormat.NativeText, - ) - self._body_search_btn.setToolTip(f"Search in response ({find_hint})") - self._body_search_btn.setObjectName("iconButton") - self._body_search_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self._body_search_btn.setCheckable(True) - self._body_search_btn.setFixedSize(28, 28) - self._body_search_btn.clicked.connect(self._toggle_search) - body_toolbar.addWidget(self._body_search_btn) - - body_toolbar.addWidget(self._body_copy_btn) - body_layout.addLayout(body_toolbar) - - # Filter bar (hidden by default) - self._filter_bar = QWidget() - filter_layout = QHBoxLayout(self._filter_bar) - filter_layout.setContentsMargins(0, 4, 0, 0) - filter_layout.setSpacing(4) - self._filter_input = QLineEdit() - self._filter_input.setPlaceholderText("Filter using JSONPath: $.store.books") - self._filter_input.returnPressed.connect(self._apply_filter) - filter_layout.addWidget(self._filter_input, 1) - self._filter_error_label = QLabel() - self._filter_error_label.setObjectName("mutedLabel") - self._filter_error_label.hide() - filter_layout.addWidget(self._filter_error_label) - self._filter_apply_btn = self._make_icon_btn("play", "Apply filter", "iconButton") - self._filter_apply_btn.clicked.connect(self._apply_filter) - filter_layout.addWidget(self._filter_apply_btn) - self._filter_clear_btn = self._make_icon_btn("x", "Clear filter", "iconButton") - self._filter_clear_btn.clicked.connect(self._clear_filter) - self._filter_clear_btn.hide() - filter_layout.addWidget(self._filter_clear_btn) - self._filter_bar.hide() - body_layout.addWidget(self._filter_bar) - self._is_filtered = False - self._filter_expression = "" - - # Search bar (hidden by default) - self._search_bar = QWidget() - search_layout = QHBoxLayout(self._search_bar) - search_layout.setContentsMargins(0, 4, 0, 0) - search_layout.setSpacing(4) - self._search_input = QLineEdit() - self._search_input.setPlaceholderText("Find in response\u2026") - self._search_input.textChanged.connect(self._on_search_text_changed) - search_layout.addWidget(self._search_input, 1) - self._search_count_label = QLabel("") - self._search_count_label.setObjectName("mutedLabel") - search_layout.addWidget(self._search_count_label) - prev_btn = self._make_icon_btn("caret-up", "Previous match", "iconButton") - prev_btn.setFixedSize(24, 24) - prev_btn.clicked.connect(self._search_prev) - search_layout.addWidget(prev_btn) - next_btn = self._make_icon_btn("caret-down", "Next match", "iconButton") - next_btn.setFixedSize(24, 24) - next_btn.clicked.connect(self._search_next) - search_layout.addWidget(next_btn) - close_btn = self._make_icon_btn("x", "Close search", "iconButton") - close_btn.setFixedSize(24, 24) - close_btn.clicked.connect(self._close_search) - search_layout.addWidget(close_btn) - self._search_bar.hide() - body_layout.addWidget(self._search_bar) - self._find_shortcut = QShortcut(QKeySequence.StandardKey.Find, self) - self._find_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) - self._find_shortcut.activated.connect(self._toggle_search) - self._search_matches: list[int] = [] - self._search_index: int = -1 - - self._body_empty_label = self._make_empty_label("No response body") - body_layout.addWidget(self._body_empty_label, 1) - body_layout.addWidget(self._body_edit, 1) - self._detail_tabs.addTab(body_tab, "Body") - def _build_headers_tab(self) -> None: """Construct the Headers tab.""" - headers_tab = QWidget() - headers_layout = QVBoxLayout(headers_tab) - headers_layout.setContentsMargins(0, 0, 0, 0) - headers_layout.setSpacing(6) - headers_toolbar = QHBoxLayout() - headers_toolbar.setContentsMargins(0, 0, 0, 0) - headers_toolbar.setSpacing(6) - headers_toolbar.addStretch() - self._headers_edit = CodeEditorWidget(read_only=True) - self._headers_copy_btn = self._make_copy_btn(lambda: self._copy_editor(self._headers_edit)) - headers_toolbar.addWidget(self._headers_copy_btn) - headers_layout.addLayout(headers_toolbar) - self._headers_empty_label = self._make_empty_label("No response headers") - headers_layout.addWidget(self._headers_empty_label, 1) - headers_layout.addWidget(self._headers_edit, 1) - self._detail_tabs.addTab(headers_tab, "Headers") - - def _build_snapshot_tab(self) -> None: - """Construct the Request Snapshot tab.""" - snapshot_tab = QWidget() - snapshot_layout = QVBoxLayout(snapshot_tab) - snapshot_layout.setContentsMargins(0, 0, 0, 0) - snapshot_layout.setSpacing(6) - snapshot_toolbar = QHBoxLayout() - snapshot_toolbar.setContentsMargins(0, 0, 0, 0) - snapshot_toolbar.setSpacing(6) - self._snapshot_view_combo = QComboBox() - self._snapshot_view_combo.addItems(["Pretty", "Raw"]) - self._snapshot_view_combo.setFixedWidth(90) - self._snapshot_view_combo.currentTextChanged.connect(self._refresh_snapshot_view) - snapshot_toolbar.addWidget(self._snapshot_view_combo) - snapshot_toolbar.addStretch() - self._snapshot_edit = CodeEditorWidget(read_only=True) - self._snapshot_copy_btn = self._make_copy_btn( - lambda: self._copy_editor(self._snapshot_edit) + 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, ) - snapshot_toolbar.addWidget(self._snapshot_copy_btn) - snapshot_layout.addLayout(snapshot_toolbar) - self._snapshot_empty_label = self._make_empty_label("No request snapshot available") - snapshot_layout.addWidget(self._snapshot_empty_label, 1) - snapshot_layout.addWidget(self._snapshot_edit, 1) - self._detail_tabs.addTab(snapshot_tab, "Request Snapshot") + 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 # -- Public API ---------------------------------------------------- def set_request_context(self, request_id: int | None, request_name: str | None) -> None: """Set the active request context shown in the panel header.""" + if request_id != self._request_id: + self._current_response_id = None self._request_id = request_id self._request_name = request_name or "" self._subtitle_label.setText(self._request_name) @@ -394,10 +333,12 @@ def clear(self) -> None: 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._snapshot_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._snapshot_view_combo, self._snapshot_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() @@ -406,10 +347,15 @@ def clear(self) -> None: self._headers_edit.set_text("") self._headers_edit.hide() self._headers_empty_label.show() - self._snapshot_edit.set_language("text") - self._snapshot_edit.set_text("") - self._snapshot_edit.hide() - self._snapshot_empty_label.show() + self._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 saved response") @@ -430,6 +376,14 @@ def _select_response(self, response_id: int | None) -> None: self._list_widget.setCurrentRow(index) self._populate_detail(self._items_by_id[response_id]) return + # Requested ID not in list — fall back to first item + if self._list_widget.count() > 0: + first = self._list_widget.item(0) + first_id = first.data(Qt.ItemDataRole.UserRole) + self._list_widget.setCurrentRow(0) + if isinstance(first_id, int) and first_id in self._items_by_id: + self._populate_detail(self._items_by_id[first_id]) + return self._set_detail_enabled(False) def _on_selection_changed(self) -> None: @@ -496,10 +450,42 @@ def _populate_detail(self, item: SavedResponseDict) -> None: self._headers_edit.hide() self._headers_empty_label.show() - # 5. Snapshot tab - self._snapshot_raw_data = item["original_request"] - self._set_combo_text(self._snapshot_view_combo, self._snapshot_view_mode) - self._refresh_snapshot_view() + # 5. Request info row (method + URL) + snapshot = item["original_request"] + self._snapshot_raw_data = snapshot + req_method = extract_snapshot_method(snapshot) + req_url = extract_snapshot_url(snapshot) + if req_method or req_url: + self._request_method_badge.setText(req_method) + colour = method_color(req_method) + self._request_method_badge.setStyleSheet( + f"background: {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() + + # 6. Request Headers tab + req_headers_text = extract_snapshot_headers(snapshot) + 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() + + # 7. Request Body tab + req_body, req_body_lang = extract_snapshot_body(snapshot) + 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) @@ -574,31 +560,22 @@ def _refresh_body_view(self, _mode: str | None = None) -> None: self._body_edit.set_text(body_text) - def _refresh_snapshot_view(self, _mode: str | None = None) -> None: - """Render the saved request snapshot using the selected view mode.""" - self._snapshot_view_mode = self._snapshot_view_combo.currentText() - if self._snapshot_raw_data is None: - self._snapshot_edit.hide() - self._snapshot_empty_label.show() + def _refresh_request_body_view(self, _mode: str | None = None) -> None: + """Render the saved request 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._snapshot_empty_label.hide() - self._snapshot_edit.show() - if isinstance(self._snapshot_raw_data, dict): - self._snapshot_edit.set_language("json") - self._snapshot_edit.set_text( - format_json_text( - self._snapshot_raw_data, - pretty=self._snapshot_view_mode == "Pretty", - ) - ) - return - - snapshot_text = str(self._snapshot_raw_data) - if self._snapshot_view_mode == "Pretty": - snapshot_text = format_code_text(snapshot_text, "text", pretty=True) - self._snapshot_edit.set_language("text") - self._snapshot_edit.set_text(snapshot_text) + 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: diff --git a/src/ui/sidebar/saved_responses/search_filter.py b/src/ui/sidebar/saved_responses/search_filter.py index ef3f7b6..f52b513 100644 --- a/src/ui/sidebar/saved_responses/search_filter.py +++ b/src/ui/sidebar/saved_responses/search_filter.py @@ -2,9 +2,23 @@ from __future__ import annotations -from PySide6.QtGui import QColor, QTextCharFormat, QTextCursor -from PySide6.QtWidgets import QLabel, QLineEdit, QPushButton, QTextEdit, QWidget - +from typing import cast + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QKeySequence, QShortcut, QTextCharFormat, QTextCursor +from PySide6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from ui.styling.icons import phi from ui.styling.theme import COLOR_WARNING from ui.widgets.code_editor import CodeEditorWidget @@ -40,8 +54,11 @@ class _PanelSearchFilterMixin: _body_language: str _body_raw_text: str _body_view_mode: str + _body_view_combo: QComboBox + _body_copy_btn: QPushButton _body_filter_btn: QPushButton _body_search_btn: QPushButton + _body_empty_label: QLabel _filter_bar: QWidget _filter_input: QLineEdit _filter_error_label: QLabel @@ -54,9 +71,141 @@ class _PanelSearchFilterMixin: _search_index: int _is_filtered: bool _filter_expression: str + _detail_tabs: QTabWidget def _refresh_body_view(self, _mode: str | None = None) -> None: ... + @staticmethod + def _make_icon_btn( + icon_name: str, + tooltip: str, + obj_name: str, + slot: object = None, + ) -> QPushButton: + return QPushButton() # overridden by host + + @staticmethod + def _make_empty_label(text: str) -> QLabel: + return QLabel() # overridden by host + + def _copy_editor(self, editor: CodeEditorWidget) -> None: ... + + # -- Body tab construction ----------------------------------------- + + def _build_body_tab(self) -> None: + """Construct the Body tab with format combo, filter, search, and editor.""" + body_tab = QWidget() + body_layout = QVBoxLayout(body_tab) + body_layout.setContentsMargins(0, 4, 0, 0) + body_layout.setSpacing(6) + body_toolbar = QHBoxLayout() + body_toolbar.setContentsMargins(0, 0, 0, 0) + body_toolbar.setSpacing(6) + self._body_view_combo = QComboBox() + self._body_view_combo.addItems(["Pretty", "Raw"]) + self._body_view_combo.setFixedWidth(90) + self._body_view_combo.currentTextChanged.connect(self._refresh_body_view) + body_toolbar.addWidget(self._body_view_combo) + body_toolbar.addStretch() + self._body_edit = CodeEditorWidget(read_only=True) + self._body_copy_btn = self._make_icon_btn( + "clipboard", + "Copy to clipboard", + "iconButton", + lambda: self._copy_editor(self._body_edit), + ) + + self._body_filter_btn = QPushButton() + self._body_filter_btn.setIcon(phi("funnel")) + self._body_filter_btn.setToolTip("Filter response (JSONPath / XPath)") + self._body_filter_btn.setObjectName("iconButton") + self._body_filter_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._body_filter_btn.setCheckable(True) + self._body_filter_btn.setFixedSize(28, 28) + self._body_filter_btn.clicked.connect(self._toggle_filter) + body_toolbar.addWidget(self._body_filter_btn) + + self._body_search_btn = QPushButton() + self._body_search_btn.setIcon(phi("magnifying-glass")) + find_hint = QKeySequence(QKeySequence.StandardKey.Find).toString( + QKeySequence.SequenceFormat.NativeText, + ) + self._body_search_btn.setToolTip(f"Search in response ({find_hint})") + self._body_search_btn.setObjectName("iconButton") + self._body_search_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._body_search_btn.setCheckable(True) + self._body_search_btn.setFixedSize(28, 28) + self._body_search_btn.clicked.connect(self._toggle_search) + body_toolbar.addWidget(self._body_search_btn) + + body_toolbar.addWidget(self._body_copy_btn) + body_layout.addLayout(body_toolbar) + + # Filter bar (hidden by default) + self._filter_bar = QWidget() + filter_layout = QHBoxLayout(self._filter_bar) + filter_layout.setContentsMargins(0, 4, 0, 0) + filter_layout.setSpacing(4) + self._filter_input = QLineEdit() + self._filter_input.setPlaceholderText("Filter using JSONPath: $.store.books") + self._filter_input.returnPressed.connect(self._apply_filter) + filter_layout.addWidget(self._filter_input, 1) + self._filter_error_label = QLabel() + self._filter_error_label.setObjectName("mutedLabel") + self._filter_error_label.hide() + filter_layout.addWidget(self._filter_error_label) + self._filter_apply_btn = self._make_icon_btn("play", "Apply filter", "iconButton") + self._filter_apply_btn.clicked.connect(self._apply_filter) + filter_layout.addWidget(self._filter_apply_btn) + self._filter_clear_btn = self._make_icon_btn("x", "Clear filter", "iconButton") + self._filter_clear_btn.clicked.connect(self._clear_filter) + self._filter_clear_btn.hide() + filter_layout.addWidget(self._filter_clear_btn) + self._filter_bar.hide() + body_layout.addWidget(self._filter_bar) + self._is_filtered = False + self._filter_expression = "" + + # Search bar (hidden by default) + self._search_bar = QWidget() + search_layout = QHBoxLayout(self._search_bar) + search_layout.setContentsMargins(0, 4, 0, 0) + search_layout.setSpacing(4) + self._search_input = QLineEdit() + self._search_input.setPlaceholderText("Find in response\u2026") + self._search_input.textChanged.connect(self._on_search_text_changed) + search_layout.addWidget(self._search_input, 1) + self._search_count_label = QLabel("") + self._search_count_label.setObjectName("mutedLabel") + search_layout.addWidget(self._search_count_label) + prev_btn = self._make_icon_btn("caret-up", "Previous match", "iconButton") + prev_btn.setFixedSize(24, 24) + prev_btn.clicked.connect(self._search_prev) + search_layout.addWidget(prev_btn) + next_btn = self._make_icon_btn("caret-down", "Next match", "iconButton") + next_btn.setFixedSize(24, 24) + next_btn.clicked.connect(self._search_next) + search_layout.addWidget(next_btn) + close_btn = self._make_icon_btn("x", "Close search", "iconButton") + close_btn.setFixedSize(24, 24) + close_btn.clicked.connect(self._close_search) + search_layout.addWidget(close_btn) + self._search_bar.hide() + body_layout.addWidget(self._search_bar) + self._find_shortcut = QShortcut( + QKeySequence.StandardKey.Find, + cast(QWidget, self), + ) + self._find_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + self._find_shortcut.activated.connect(self._toggle_search) + self._search_matches: list[int] = [] + self._search_index: int = -1 + + self._body_empty_label = self._make_empty_label("No response body") + body_layout.addWidget(self._body_empty_label, 1) + body_layout.addWidget(self._body_edit, 1) + self._detail_tabs.addTab(body_tab, "Body") + # -- Search -------------------------------------------------------- def _reset_search_filter(self) -> None: diff --git a/src/ui/sidebar/sidebar_widget.py b/src/ui/sidebar/sidebar_widget.py index e7a64a7..f0410be 100644 --- a/src/ui/sidebar/sidebar_widget.py +++ b/src/ui/sidebar/sidebar_widget.py @@ -117,7 +117,7 @@ def __init__(self, parent: QWidget | None = None) -> None: # Derive sizes from the application font. em = self.fontMetrics().height() self._rail_width: int = round(2.0 * em) - self._icon_size: int = em + self._icon_size: int = round(1.25 * em) self._btn_size: int = self._rail_width - round(0.35 * em) self._panel_hint_width: int = round(15.0 * em) @@ -135,7 +135,7 @@ def __init__(self, parent: QWidget | None = None) -> None: # --- Rail layout ---------------------------------------------- rail_layout = QVBoxLayout(self) rail_layout.setContentsMargins(0, 6, 0, 6) - rail_layout.setSpacing(2) + rail_layout.setSpacing(12) self._var_btn = self._make_rail_button( "brackets-curly", @@ -239,6 +239,13 @@ def panel_open(self) -> bool: """Return whether any panel is currently visible.""" return self._active_panel is not None + @property + def flyout_width(self) -> int: + """Return the current flyout width in pixels (0 when collapsed).""" + if not self._splitter or self._flyout_idx < 0: + return 0 + return self._splitter.sizes()[self._flyout_idx] + def show_request_panels( self, variables: dict[str, VariableDetail], @@ -351,6 +358,7 @@ def _make_rail_button(self, icon_name: str, tooltip: str) -> QToolButton: btn = QToolButton() btn.setObjectName("sidebarRailButton") btn.setIcon(phi(icon_name, size=self._icon_size)) + btn.setIconSize(QSize(self._icon_size, self._icon_size)) btn.setToolTip(tooltip) btn.setCheckable(True) btn.setFixedSize(self._btn_size, self._btn_size) @@ -396,15 +404,16 @@ def _close_panel(self) -> None: self._saved_btn.setChecked(False) self._collapse_flyout() - def _expand_flyout(self) -> None: + def _expand_flyout(self, target_width: int | None = None) -> None: """Expand the flyout in the parent splitter via setSizes.""" if not self._splitter or self._flyout_idx < 0: return + want = target_width if target_width is not None else self._panel_hint_width sizes = self._splitter.sizes() - if sizes[self._flyout_idx] >= self._panel_hint_width: + if sizes[self._flyout_idx] >= want: return # Steal space from the content area (index 0). - need = self._panel_hint_width - sizes[self._flyout_idx] + need = want - sizes[self._flyout_idx] give = min(need, sizes[0]) sizes[0] -= give sizes[self._flyout_idx] += give diff --git a/src/ui/styling/global_qss.py b/src/ui/styling/global_qss.py index 66471ea..788633c 100644 --- a/src/ui/styling/global_qss.py +++ b/src/ui/styling/global_qss.py @@ -294,6 +294,16 @@ def build_global_qss(p: ThemePalette) -> str: color: {p["accent"]}; border-bottom: 2px solid {p["accent"]}; }} + /* Tab overflow scroll buttons — input_bg box, 1px border, + sharp corners, accent border on hover. Keep for all QTabBars. */ + QTabBar QToolButton {{ + background: {p["input_bg"]}; + border: 1px solid {p["border"]}; + border-radius: 0px; + }} + QTabBar QToolButton:hover {{ + border-color: {p["accent"]}; + }} /* ---- Progress bars ------------------------------------------ */ QProgressBar {{ @@ -644,7 +654,7 @@ def build_global_qss(p: ThemePalette) -> str: background: transparent; border: none; border-radius: 4px; - margin: 2px 3px; + margin: 2px 1px; color: {p["text_muted"]}; }} QToolButton[objectName="sidebarRailButton"]:hover {{ diff --git a/tests/ui/sidebar/test_saved_responses_panel.py b/tests/ui/sidebar/test_saved_responses_panel.py index 5de834a..fb69073 100644 --- a/tests/ui/sidebar/test_saved_responses_panel.py +++ b/tests/ui/sidebar/test_saved_responses_panel.py @@ -116,12 +116,10 @@ def test_legacy_dict_headers_do_not_crash(self, qapp: QApplication, qtbot) -> No ] ) assert "Content-Type: application/json" in panel._headers_edit.toPlainText() - assert '"Accept": "application/json"' in panel._snapshot_edit.toPlainText() + assert "Accept: application/json" in panel._req_headers_edit.toPlainText() - def test_snapshot_view_can_switch_between_pretty_and_raw( - self, qapp: QApplication, qtbot - ) -> None: - """Saved request snapshot can switch between pretty and compact JSON.""" + def test_request_method_and_url_shown_in_detail(self, qapp: QApplication, qtbot) -> None: + """Method badge and URL label are populated from the request snapshot.""" panel = SavedResponsesPanel() qtbot.addWidget(panel) panel.set_request_context(1, "Search") @@ -136,21 +134,24 @@ def test_snapshot_view_can_switch_between_pretty_and_raw( "headers": [], "body": "ok", "preview_language": "text", - "original_request": {"method": "GET", "url": "https://example.com"}, + "original_request": { + "method": "GET", + "url": {"raw": "https://example.com/api"}, + "header": [], + }, "created_at": "2026-03-12 10:00", "body_size": 2, } ] ) - assert "\n" in panel._snapshot_edit.toPlainText() - panel._snapshot_view_combo.setCurrentText("Raw") - assert panel._snapshot_edit.toPlainText() == '{"method":"GET","url":"https://example.com"}' + assert panel._request_method_badge.text() == "GET" + assert "https://example.com/api" in panel._request_url_label.text() def test_view_modes_persist_across_saved_response_selection( self, qapp: QApplication, qtbot ) -> None: - """Body and snapshot view modes stay on the user's last chosen mode.""" + """Body and request body view modes stay on the user's last chosen mode.""" panel = SavedResponsesPanel() qtbot.addWidget(panel) panel.set_request_context(1, "Search") @@ -165,7 +166,12 @@ def test_view_modes_persist_across_saved_response_selection( "headers": [], "body": '{"first":true}', "preview_language": "json", - "original_request": {"method": "GET", "url": "https://example.com/a"}, + "original_request": { + "method": "GET", + "url": {"raw": "https://example.com/a"}, + "header": [], + "body": {"mode": "raw", "raw": '{"a":1}'}, + }, "created_at": "2026-03-12 10:00", "body_size": 14, }, @@ -178,7 +184,12 @@ def test_view_modes_persist_across_saved_response_selection( "headers": [], "body": '{"second":true}', "preview_language": "json", - "original_request": {"method": "GET", "url": "https://example.com/b"}, + "original_request": { + "method": "POST", + "url": {"raw": "https://example.com/b"}, + "header": [], + "body": {"mode": "raw", "raw": '{"b":2}'}, + }, "created_at": "2026-03-12 10:01", "body_size": 15, }, @@ -186,16 +197,14 @@ def test_view_modes_persist_across_saved_response_selection( ) panel._body_view_combo.setCurrentText("Raw") - panel._snapshot_view_combo.setCurrentText("Raw") + panel._req_body_view_combo.setCurrentText("Raw") panel.select_response(13) assert panel._body_view_combo.currentText() == "Raw" - assert panel._snapshot_view_combo.currentText() == "Raw" + assert panel._req_body_view_combo.currentText() == "Raw" assert panel._body_edit.toPlainText() == '{"second":true}' - assert ( - panel._snapshot_edit.toPlainText() == '{"method":"GET","url":"https://example.com/b"}' - ) + assert panel._req_body_edit.toPlainText() == '{"b":2}' def test_save_current_signal(self, qapp: QApplication, qtbot) -> None: """Save Current button emits its signal when enabled.""" @@ -416,6 +425,64 @@ def test_body_language_detected_from_json_body(self, qapp: QApplication, qtbot) ) assert panel._body_language == "json" + def test_request_body_tab_shows_pretty_json(self, qapp: QApplication, qtbot) -> None: + """Request body tab renders Postman body.raw with pretty formatting.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "Body", + "status": "OK", + "code": 200, + "headers": [], + "body": "ok", + "preview_language": "text", + "original_request": { + "method": "POST", + "url": {"raw": "https://example.com"}, + "header": [], + "body": { + "mode": "raw", + "raw": '{"key":"value"}', + "options": {"raw": {"language": "json"}}, + }, + }, + "created_at": "2026-03-12 10:00", + "body_size": 2, + } + ] + ) + assert '"key"' in panel._req_body_edit.toPlainText() + assert not panel._req_body_empty_label.isVisible() + + def test_request_info_hidden_when_no_snapshot(self, qapp: QApplication, qtbot) -> None: + """Method badge and URL are hidden when no original_request exists.""" + panel = SavedResponsesPanel() + qtbot.addWidget(panel) + panel.set_request_context(1, "Search") + panel.set_saved_responses( + [ + { + "id": 10, + "request_id": 1, + "name": "No snapshot", + "status": "OK", + "code": 200, + "headers": [], + "body": "ok", + "preview_language": "text", + "original_request": None, + "created_at": "2026-03-12 10:00", + "body_size": 2, + } + ] + ) + assert panel._request_info_widget.isHidden() + class TestDetectBodyLanguage: """Unit tests for the detect_body_language helper.""" diff --git a/tests/unit/services/test_service.py b/tests/unit/services/test_service.py index e5ecaeb..24331d3 100644 --- a/tests/unit/services/test_service.py +++ b/tests/unit/services/test_service.py @@ -249,6 +249,46 @@ def test_get_saved_response_normalizes_legacy_dict_headers(self) -> None: {"key": "Accept", "value": "application/json"} ] + def test_save_response_converts_editor_format_to_postman(self) -> None: + """Editor-format original_request is saved in Postman shape.""" + svc = CollectionService() + coll = svc.create_collection("C") + req = svc.create_request(coll.id, "POST", "http://x", "R") + editor_dict = { + "method": "POST", + "url": "http://x/create", + "body": '{"a":1}', + "body_mode": "raw", + "body_options": {"raw": {"language": "json"}}, + "headers": [{"key": "Content-Type", "value": "application/json"}], + "request_parameters": None, + "description": "test", + "scripts": None, + "auth": None, + } + sr_id = svc.save_response( + req.id, + "Example", + "OK", + 200, + [], + "body", + original_request=editor_dict, + ) + response = svc.get_saved_response(sr_id) + assert response is not None + snap = response["original_request"] + assert snap is not None + assert snap["method"] == "POST" + assert snap["url"] == {"raw": "http://x/create"} + assert snap["header"] == [{"key": "Content-Type", "value": "application/json"}] + assert snap["body"]["mode"] == "raw" + assert snap["body"]["raw"] == '{"a":1}' + assert snap["body"]["options"] == {"raw": {"language": "json"}} + # Editor-specific keys should not be present + assert "body_mode" not in snap + assert "request_parameters" not in snap + class TestCollectionUpdates: """Tests for updating collection fields via the service layer.""" @@ -336,3 +376,4 @@ def test_recent_requests_empty(self) -> None: coll = svc.create_collection("Empty") assert svc.get_recent_requests(coll.id) == [] assert svc.get_recent_requests(coll.id) == [] + assert svc.get_recent_requests(coll.id) == []