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:
parent
c76d574832
commit
df30ad6388
@ -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>
|
||||
|
||||
@ -34,6 +34,7 @@ roster_sources = [
|
||||
'http_client.py',
|
||||
'history_manager.py',
|
||||
'project_manager.py',
|
||||
'tab_manager.py',
|
||||
'constants.py',
|
||||
'icon_picker_dialog.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
|
||||
)
|
||||
|
||||
143
src/tab_manager.py
Normal file
143
src/tab_manager.py
Normal 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
|
||||
163
src/window.py
163
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", ["<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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user