From 16a1e0e7cabe3263d24f94dd1f0c5875cabf63dd Mon Sep 17 00:00:00 2001 From: vesp Date: Sat, 20 Dec 2025 18:56:11 +0100 Subject: [PATCH] Add Tabbed interface - Add New Request Button - Implement Tab Management System - Opening saved requests from sidebar creates new tabs - Response Tracking --- src/main-window.ui | 38 +++++++++-- src/meson.build | 1 + src/models.py | 51 ++++++++++++++ src/tab_manager.py | 143 +++++++++++++++++++++++++++++++++++++++ src/window.py | 163 ++++++++++++++++++++++++++++++++++++--------- 5 files changed, 358 insertions(+), 38 deletions(-) create mode 100644 src/tab_manager.py diff --git a/src/main-window.ui b/src/main-window.ui index 2697a35..0b42c6b 100644 --- a/src/main-window.ui +++ b/src/main-window.ui @@ -96,12 +96,40 @@ + + + + horizontal + 0 + False + + + + - - True - open-menu-symbolic - Main Menu - primary_menu + + 6 + + + + + list-add-symbolic + New Request (Ctrl+T) + + + + + + + + True + open-menu-symbolic + Main Menu + primary_menu + + diff --git a/src/meson.build b/src/meson.build index a0444dc..3925436 100644 --- a/src/meson.build +++ b/src/meson.build @@ -34,6 +34,7 @@ roster_sources = [ 'http_client.py', 'history_manager.py', 'project_manager.py', + 'tab_manager.py', 'constants.py', 'icon_picker_dialog.py', ] diff --git a/src/models.py b/src/models.py index 0b58c4b..8385c15 100644 --- a/src/models.py +++ b/src/models.py @@ -150,3 +150,54 @@ class Project: created_at=data['created_at'], icon=data.get('icon', 'folder-symbolic') # Default for old data ) + + +@dataclass +class RequestTab: + """Represents an open request tab in the UI.""" + id: str # UUID for tab identification + name: str # Display name (from SavedRequest or URL) + request: HttpRequest # Current request state + response: Optional[HttpResponse] = None # Last response (if sent) + saved_request_id: Optional[str] = None # ID if from SavedRequest + modified: bool = False # True if has unsaved changes + original_request: Optional[HttpRequest] = None # For change detection + + def is_modified(self) -> bool: + """Check if current request differs from original.""" + if not self.original_request: + # New unsaved request - consider modified if has content + return bool(self.request.url or self.request.body or self.request.headers) + + return ( + self.request.method != self.original_request.method or + self.request.url != self.original_request.url or + self.request.headers != self.original_request.headers or + self.request.body != self.original_request.body or + self.request.syntax != self.original_request.syntax + ) + + def to_dict(self): + """Convert to dictionary for JSON serialization (for session persistence).""" + return { + 'id': self.id, + 'name': self.name, + 'request': self.request.to_dict(), + 'response': self.response.to_dict() if self.response else None, + 'saved_request_id': self.saved_request_id, + 'modified': self.modified, + 'original_request': self.original_request.to_dict() if self.original_request else None + } + + @classmethod + def from_dict(cls, data): + """Create instance from dictionary (for session restoration).""" + return cls( + id=data['id'], + name=data['name'], + request=HttpRequest.from_dict(data['request']), + response=HttpResponse.from_dict(data['response']) if data.get('response') else None, + saved_request_id=data.get('saved_request_id'), + modified=data.get('modified', False), + original_request=HttpRequest.from_dict(data['original_request']) if data.get('original_request') else None + ) diff --git a/src/tab_manager.py b/src/tab_manager.py new file mode 100644 index 0000000..8a58021 --- /dev/null +++ b/src/tab_manager.py @@ -0,0 +1,143 @@ +# tab_manager.py +# +# Copyright 2025 Pavel Baksy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List, Optional +import uuid +import json +from pathlib import Path + +from .models import RequestTab, HttpRequest, HttpResponse + + +class TabManager: + """Manages open request tabs.""" + + def __init__(self): + self.tabs: List[RequestTab] = [] + self.active_tab_id: Optional[str] = None + + def create_tab(self, name: str, request: HttpRequest, + saved_request_id: Optional[str] = None, + response: Optional[HttpResponse] = None) -> RequestTab: + """Create a new tab and add it to the collection.""" + tab_id = str(uuid.uuid4()) + + # Store original request for change detection + # Make a copy to avoid reference issues + original_request = HttpRequest( + method=request.method, + url=request.url, + headers=request.headers.copy(), + body=request.body, + syntax=request.syntax + ) if saved_request_id else None + + tab = RequestTab( + id=tab_id, + name=name, + request=request, + response=response, + saved_request_id=saved_request_id, + modified=False, + original_request=original_request + ) + + self.tabs.append(tab) + self.active_tab_id = tab_id + + return tab + + def close_tab(self, tab_id: str) -> bool: + """Close a tab by ID. Returns True if successful.""" + tab = self.get_tab_by_id(tab_id) + if not tab: + return False + + self.tabs.remove(tab) + + # Update active tab if we closed the active one + if self.active_tab_id == tab_id: + if self.tabs: + self.active_tab_id = self.tabs[-1].id # Switch to last tab + else: + self.active_tab_id = None + + return True + + def get_active_tab(self) -> Optional[RequestTab]: + """Get the currently active tab.""" + if not self.active_tab_id: + return None + return self.get_tab_by_id(self.active_tab_id) + + def set_active_tab(self, tab_id: str) -> None: + """Set the active tab by ID.""" + if self.get_tab_by_id(tab_id): + self.active_tab_id = tab_id + + def get_tab_by_id(self, tab_id: str) -> Optional[RequestTab]: + """Get a tab by its ID.""" + for tab in self.tabs: + if tab.id == tab_id: + return tab + return None + + def has_modified_tabs(self) -> bool: + """Check if any tabs have unsaved changes.""" + return any(tab.is_modified() for tab in self.tabs) + + def get_tab_index(self, tab_id: str) -> int: + """Get the index of a tab by ID. Returns -1 if not found.""" + for i, tab in enumerate(self.tabs): + if tab.id == tab_id: + return i + return -1 + + def save_session(self, filepath: str) -> None: + """Save all tabs to a JSON file for session persistence.""" + session_data = { + 'tabs': [tab.to_dict() for tab in self.tabs], + 'active_tab_id': self.active_tab_id + } + + # Ensure directory exists + path = Path(filepath) + path.parent.mkdir(parents=True, exist_ok=True) + + with open(filepath, 'w') as f: + json.dump(session_data, f, indent=2) + + def load_session(self, filepath: str) -> None: + """Load tabs from a JSON file to restore session.""" + try: + with open(filepath, 'r') as f: + session_data = json.load(f) + + self.tabs = [RequestTab.from_dict(tab_data) for tab_data in session_data.get('tabs', [])] + self.active_tab_id = session_data.get('active_tab_id') + + # Validate active_tab_id still exists + if self.active_tab_id and not self.get_tab_by_id(self.active_tab_id): + self.active_tab_id = self.tabs[0].id if self.tabs else None + + except (FileNotFoundError, json.JSONDecodeError, KeyError) as e: + # If session file is invalid, start fresh + print(f"Failed to load session: {e}") + self.tabs = [] + self.active_tab_id = None diff --git a/src/window.py b/src/window.py index f2026b6..9f7ee59 100644 --- a/src/window.py +++ b/src/window.py @@ -20,10 +20,11 @@ import gi gi.require_version('GtkSource', '5') from gi.repository import Adw, Gtk, GLib, Gio, GtkSource -from .models import HttpRequest, HttpResponse, HistoryEntry +from .models import HttpRequest, HttpResponse, HistoryEntry, RequestTab from .http_client import HttpClient from .history_manager import HistoryManager from .project_manager import ProjectManager +from .tab_manager import TabManager from .icon_picker_dialog import IconPickerDialog from .widgets.header_row import HeaderRow from .widgets.history_item import HistoryItem @@ -32,6 +33,7 @@ from datetime import datetime import threading import json import xml.dom.minidom +import uuid @Gtk.Template(resource_path='/cz/vesp/roster/main-window.ui') @@ -42,6 +44,8 @@ class RosterWindow(Adw.ApplicationWindow): method_dropdown = Gtk.Template.Child() url_entry = Gtk.Template.Child() send_button = Gtk.Template.Child() + new_request_button = Gtk.Template.Child() + tab_bar_container = Gtk.Template.Child() # Panes main_pane = Gtk.Template.Child() @@ -69,6 +73,13 @@ class RosterWindow(Adw.ApplicationWindow): self.http_client = HttpClient() self.history_manager = HistoryManager() self.project_manager = ProjectManager() + self.tab_manager = TabManager() + + # Tab tracking - maps tab_id to UI state + self.tab_notebook = Gtk.Notebook() + self.tab_notebook.set_show_tabs(False) # We'll use custom tab bar + self.tab_notebook.set_show_border(False) + self.current_tab_id = None # Create window actions self._create_actions() @@ -77,6 +88,7 @@ class RosterWindow(Adw.ApplicationWindow): self._setup_method_dropdown() self._setup_request_tabs() self._setup_response_tabs() + self._setup_tab_system() self._load_history() self._load_projects() @@ -86,6 +98,9 @@ class RosterWindow(Adw.ApplicationWindow): # Set split pane position to center after window is shown self.connect("map", self._on_window_mapped) + # Create first tab + self._create_new_tab() + def _on_window_mapped(self, widget): """Set split pane position to center when window is mapped.""" # Use idle_add to ensure the widget is fully allocated @@ -162,9 +177,109 @@ class RosterWindow(Adw.ApplicationWindow): language = language_manager.get_language('xml') buffer.set_language(language) + def _setup_tab_system(self): + """Set up the tab system.""" + # Connect new request button + self.new_request_button.connect("clicked", self._on_new_request_clicked) + + def _create_new_tab(self, name="New Request", request=None, saved_request_id=None): + """Create a new tab (simplified version - just clears current request for now).""" + # Create tab in tab manager + if not request: + request = HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW") + + tab = self.tab_manager.create_tab(name, request, saved_request_id) + self.current_tab_id = tab.id + + # For simplified version, just load this request into UI + self._load_request_to_ui(request) + + # Update tab bar visibility + self._update_tab_bar_visibility() + + return tab.id + + def _update_tab_bar_visibility(self): + """Show/hide tab bar based on number of tabs.""" + num_tabs = len(self.tab_manager.tabs) + # Show tab bar only if we have 2 or more tabs + self.tab_bar_container.set_visible(num_tabs >= 2) + + # Update tab bar with buttons (simplified - just show count for now) + if num_tabs >= 2: + # Clear existing children + child = self.tab_bar_container.get_first_child() + while child: + next_child = child.get_next_sibling() + self.tab_bar_container.remove(child) + child = next_child + + # Add simple tab buttons + for tab in self.tab_manager.tabs: + tab_btn = Gtk.Button(label=tab.name[:20]) # Truncate long names + tab_btn.add_css_class("flat") + if tab.id == self.current_tab_id: + tab_btn.add_css_class("suggested-action") + tab_btn.connect("clicked", lambda btn, tid=tab.id: self._switch_to_tab(tid)) + self.tab_bar_container.append(tab_btn) + + def _switch_to_tab(self, tab_id): + """Switch to a different tab.""" + # Save current tab state + if self.current_tab_id: + current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id) + if current_tab: + current_tab.request = self._build_request_from_ui() + + # Load new tab + tab = self.tab_manager.get_tab_by_id(tab_id) + if tab: + self.current_tab_id = tab_id + self._load_request_to_ui(tab.request) + if tab.response: + self._display_response(tab.response) + self._update_tab_bar_visibility() + + def _load_request_to_ui(self, request): + """Load a request into the UI.""" + # Set method + methods = ["GET", "POST", "PUT", "DELETE"] + if request.method in methods: + self.method_dropdown.set_selected(methods.index(request.method)) + + # Set URL + self.url_entry.set_text(request.url) + + # Clear and set headers + while child := self.headers_listbox.get_first_child(): + self.headers_listbox.remove(child) + + for key, value in request.headers.items(): + self._add_header_row(key, value) + + if not request.headers: + self._add_header_row() + + # Set body + buffer = self.body_sourceview.get_buffer() + buffer.set_text(request.body) + + # Set syntax + syntax_options = ["RAW", "JSON", "XML"] + if request.syntax in syntax_options: + self.body_language_dropdown.set_selected(syntax_options.index(request.syntax)) + + def _on_new_request_clicked(self, button): + """Handle New Request button click.""" + self._create_new_tab() + def _create_actions(self): """Create window-level actions.""" - pass + # New tab shortcut + action = Gio.SimpleAction.new("new-tab", None) + action.connect("activate", lambda a, p: self._on_new_request_clicked(None)) + self.add_action(action) + self.get_application().set_accels_for_action("win.new-tab", ["t"]) def _setup_method_dropdown(self): """Populate HTTP method dropdown.""" @@ -377,6 +492,12 @@ class RosterWindow(Adw.ApplicationWindow): self.send_button.set_sensitive(True) self.send_button.set_label("Send") + # Save response to current tab + if self.current_tab_id: + current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id) + if current_tab: + current_tab.response = response + # Create history entry entry = HistoryEntry( timestamp=datetime.now().isoformat(), @@ -839,39 +960,15 @@ class RosterWindow(Adw.ApplicationWindow): dialog.present(self) def _on_load_request(self, widget, saved_request): - """Load saved request into UI (direct load, no warning).""" + """Load saved request in new tab.""" req = saved_request.request - # Set method - methods = ["GET", "POST", "PUT", "DELETE"] - if req.method in methods: - self.method_dropdown.set_selected(methods.index(req.method)) - - # Set URL - self.url_entry.set_text(req.url) - - # Clear headers - while child := self.headers_listbox.get_first_child(): - self.headers_listbox.remove(child) - - # Set headers - for key, value in req.headers.items(): - self._add_header_row(key, value) - - if not req.headers: - self._add_header_row() - - # Set body - buffer = self.body_sourceview.get_buffer() - buffer.set_text(req.body) - - # Restore syntax selection - syntax_options = ["RAW", "JSON", "XML"] - syntax = getattr(req, 'syntax', 'RAW') # Default to RAW for old requests - if syntax in syntax_options: - self.body_language_dropdown.set_selected(syntax_options.index(syntax)) - else: - self.body_language_dropdown.set_selected(0) # Default to RAW + # Create a new tab with this request + self._create_new_tab( + name=saved_request.name, + request=req, + saved_request_id=saved_request.id + ) # Switch to headers tab self.request_stack.set_visible_child_name("headers")