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 --> <!-- Main Header Bar -->
<child> <child>
<object class="AdwHeaderBar"> <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"> <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"> <object class="GtkMenuButton">
<property name="primary">True</property> <property name="primary">True</property>
<property name="icon-name">open-menu-symbolic</property> <property name="icon-name">open-menu-symbolic</property>
@ -106,6 +132,8 @@
</child> </child>
</object> </object>
</child> </child>
</object>
</child>
<!-- Main Content --> <!-- Main Content -->
<child> <child>

View File

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

View File

@ -150,3 +150,54 @@ class Project:
created_at=data['created_at'], created_at=data['created_at'],
icon=data.get('icon', 'folder-symbolic') # Default for old data 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 import gi
gi.require_version('GtkSource', '5') gi.require_version('GtkSource', '5')
from gi.repository import Adw, Gtk, GLib, Gio, GtkSource 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 .http_client import HttpClient
from .history_manager import HistoryManager from .history_manager import HistoryManager
from .project_manager import ProjectManager from .project_manager import ProjectManager
from .tab_manager import TabManager
from .icon_picker_dialog import IconPickerDialog from .icon_picker_dialog import IconPickerDialog
from .widgets.header_row import HeaderRow from .widgets.header_row import HeaderRow
from .widgets.history_item import HistoryItem from .widgets.history_item import HistoryItem
@ -32,6 +33,7 @@ from datetime import datetime
import threading import threading
import json import json
import xml.dom.minidom import xml.dom.minidom
import uuid
@Gtk.Template(resource_path='/cz/vesp/roster/main-window.ui') @Gtk.Template(resource_path='/cz/vesp/roster/main-window.ui')
@ -42,6 +44,8 @@ class RosterWindow(Adw.ApplicationWindow):
method_dropdown = Gtk.Template.Child() method_dropdown = Gtk.Template.Child()
url_entry = Gtk.Template.Child() url_entry = Gtk.Template.Child()
send_button = Gtk.Template.Child() send_button = Gtk.Template.Child()
new_request_button = Gtk.Template.Child()
tab_bar_container = Gtk.Template.Child()
# Panes # Panes
main_pane = Gtk.Template.Child() main_pane = Gtk.Template.Child()
@ -69,6 +73,13 @@ class RosterWindow(Adw.ApplicationWindow):
self.http_client = HttpClient() self.http_client = HttpClient()
self.history_manager = HistoryManager() self.history_manager = HistoryManager()
self.project_manager = ProjectManager() 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 # Create window actions
self._create_actions() self._create_actions()
@ -77,6 +88,7 @@ class RosterWindow(Adw.ApplicationWindow):
self._setup_method_dropdown() self._setup_method_dropdown()
self._setup_request_tabs() self._setup_request_tabs()
self._setup_response_tabs() self._setup_response_tabs()
self._setup_tab_system()
self._load_history() self._load_history()
self._load_projects() self._load_projects()
@ -86,6 +98,9 @@ class RosterWindow(Adw.ApplicationWindow):
# Set split pane position to center after window is shown # Set split pane position to center after window is shown
self.connect("map", self._on_window_mapped) self.connect("map", self._on_window_mapped)
# Create first tab
self._create_new_tab()
def _on_window_mapped(self, widget): def _on_window_mapped(self, widget):
"""Set split pane position to center when window is mapped.""" """Set split pane position to center when window is mapped."""
# Use idle_add to ensure the widget is fully allocated # Use idle_add to ensure the widget is fully allocated
@ -162,9 +177,109 @@ class RosterWindow(Adw.ApplicationWindow):
language = language_manager.get_language('xml') language = language_manager.get_language('xml')
buffer.set_language(language) 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): def _create_actions(self):
"""Create window-level actions.""" """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): def _setup_method_dropdown(self):
"""Populate HTTP method dropdown.""" """Populate HTTP method dropdown."""
@ -377,6 +492,12 @@ class RosterWindow(Adw.ApplicationWindow):
self.send_button.set_sensitive(True) self.send_button.set_sensitive(True)
self.send_button.set_label("Send") 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 # Create history entry
entry = HistoryEntry( entry = HistoryEntry(
timestamp=datetime.now().isoformat(), timestamp=datetime.now().isoformat(),
@ -839,39 +960,15 @@ class RosterWindow(Adw.ApplicationWindow):
dialog.present(self) dialog.present(self)
def _on_load_request(self, widget, saved_request): 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 req = saved_request.request
# Set method # Create a new tab with this request
methods = ["GET", "POST", "PUT", "DELETE"] self._create_new_tab(
if req.method in methods: name=saved_request.name,
self.method_dropdown.set_selected(methods.index(req.method)) request=req,
saved_request_id=saved_request.id
# 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
# Switch to headers tab # Switch to headers tab
self.request_stack.set_visible_child_name("headers") self.request_stack.set_visible_child_name("headers")