Add Tabbed interface

- Add New Request Button
   - Implement Tab Management System
   - Opening saved requests from sidebar creates new tabs
   - Response Tracking
This commit is contained in:
Pavel Baksy 2025-12-20 18:56:11 +01:00
parent c76d574832
commit df30ad6388
5 changed files with 358 additions and 38 deletions

View File

@ -96,7 +96,33 @@
<!-- Main Header Bar -->
<child>
<object class="AdwHeaderBar">
<!-- Tab bar container on the left (hidden when only 1 tab) -->
<child type="start">
<object class="GtkBox" id="tab_bar_container">
<property name="orientation">horizontal</property>
<property name="spacing">0</property>
<property name="visible">False</property>
</object>
</child>
<!-- Right side buttons -->
<child type="end">
<object class="GtkBox">
<property name="spacing">6</property>
<!-- New Request Button -->
<child>
<object class="GtkButton" id="new_request_button">
<property name="icon-name">list-add-symbolic</property>
<property name="tooltip-text">New Request (Ctrl+T)</property>
<style>
<class name="flat"/>
</style>
</object>
</child>
<!-- Main Menu -->
<child>
<object class="GtkMenuButton">
<property name="primary">True</property>
<property name="icon-name">open-menu-symbolic</property>
@ -106,6 +132,8 @@
</child>
</object>
</child>
</object>
</child>
<!-- Main Content -->
<child>

View File

@ -34,6 +34,7 @@ roster_sources = [
'http_client.py',
'history_manager.py',
'project_manager.py',
'tab_manager.py',
'constants.py',
'icon_picker_dialog.py',
]

View File

@ -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
)

143
src/tab_manager.py Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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

View File

@ -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", ["<Control>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")