roster/src/window.py
Pavel Baksy 0a5c1c59af Maximize header space and enable tab scrolling
Remove application title from main header bar and wrap tabs in scrollable container. This prevents window resizing when many tabs are open and follows modern GNOME app patterns.
2025-12-23 12:36:59 +01:00

1474 lines
56 KiB
Python

# window.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
import gi
gi.require_version('GtkSource', '5')
from gi.repository import Adw, Gtk, GLib, Gio, GtkSource
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
from .widgets.project_item import ProjectItem
from datetime import datetime
import json
import xml.dom.minidom
import uuid
@Gtk.Template(resource_path='/cz/vesp/roster/main-window.ui')
class RosterWindow(Adw.ApplicationWindow):
__gtype_name__ = 'RosterWindow'
# Top bar widgets
method_dropdown = Gtk.Template.Child()
url_entry = Gtk.Template.Child()
send_button = Gtk.Template.Child()
new_request_button = Gtk.Template.Child()
tab_bar_scroll = Gtk.Template.Child()
tab_bar_container = Gtk.Template.Child()
# Panes
main_pane = Gtk.Template.Child()
split_pane = Gtk.Template.Child()
# Sidebar widgets
projects_listbox = Gtk.Template.Child()
add_project_button = Gtk.Template.Child()
save_request_button = Gtk.Template.Child()
# Containers for tabs
request_tabs_container = Gtk.Template.Child()
response_tabs_container = Gtk.Template.Child()
# Status bar
status_label = Gtk.Template.Child()
time_label = Gtk.Template.Child()
# History
history_listbox = Gtk.Template.Child()
def __init__(self, **kwargs):
super().__init__(**kwargs)
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()
# Setup custom CSS
self._setup_custom_css()
# Setup UI
self._setup_method_dropdown()
self._setup_request_tabs()
self._setup_response_tabs()
self._setup_tab_system()
self._load_history()
self._load_projects()
# Add initial header row
self._add_header_row()
# Set split pane position to center after window is shown
self.connect("map", self._on_window_mapped)
# Connect to close-request to warn about unsaved changes
self.connect("close-request", self._on_close_request)
# 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
GLib.idle_add(self._center_split_pane)
def _center_split_pane(self):
"""Center the split pane divider."""
# Get the allocated width of the paned widget
allocation = self.split_pane.get_allocation()
if allocation.width > 0:
self.split_pane.set_position(allocation.width // 2)
return False # Don't repeat
def _on_close_request(self, window):
"""Handle window close request - warn if there are unsaved changes."""
# Save current tab state first
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()
# Check if any tabs have unsaved changes
modified_tabs = [tab for tab in self.tab_manager.tabs if tab.is_modified()]
if modified_tabs:
# Show warning dialog
dialog = Adw.AlertDialog()
dialog.set_heading("Unsaved Changes")
if len(modified_tabs) == 1:
dialog.set_body(f"'{modified_tabs[0].name}' has unsaved changes. Close anyway?")
else:
dialog.set_body(f"{len(modified_tabs)} requests have unsaved changes. Close anyway?")
dialog.add_response("cancel", "Cancel")
dialog.add_response("close", "Close")
dialog.set_response_appearance("close", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
dialog.connect("response", self._on_close_app_dialog_response)
dialog.present(self)
# Prevent closing for now - will close if user confirms
return True
# No unsaved changes, allow closing
return False
def _on_close_app_dialog_response(self, dialog, response):
"""Handle close application dialog response."""
if response == "close":
# User confirmed - close the window
self.destroy()
def _setup_custom_css(self):
"""Setup custom CSS for enhanced tab styling."""
css_provider = Gtk.CssProvider()
css_provider.load_from_data(b"""
/* Document tabs in header bar */
.tab-button {
margin: 0 2px;
padding: 0;
border-radius: 6px;
background: alpha(@window_fg_color, 0.08);
transition: all 200ms ease;
}
.tab-button:hover {
background: alpha(@window_fg_color, 0.12);
}
.tab-button-active {
background: alpha(@accent_bg_color, 0.2);
box-shadow: inset 0 -2px 0 0 @accent_bg_color;
}
.tab-button-active:hover {
background: alpha(@accent_bg_color, 0.25);
}
/* Tab label button */
.tab-label-btn {
padding: 4px 12px;
min-height: 28px;
border-radius: 6px 0 0 6px;
}
.tab-button-active .tab-label-btn {
font-weight: 600;
}
/* Tab close button */
.tab-close-btn {
padding: 4px 8px;
min-width: 24px;
min-height: 24px;
margin: 2px 4px 2px 0;
border-radius: 0 6px 6px 0;
opacity: 0.7;
}
.tab-close-btn:hover {
opacity: 1;
background: alpha(@window_fg_color, 0.1);
}
.tab-button-active .tab-close-btn:hover {
background: alpha(@accent_bg_color, 0.3);
}
/* Stack switchers styling (Headers/Body tabs) */
stackswitcher button {
padding: 6px 16px;
min-height: 32px;
border-radius: 6px;
margin: 0 2px;
}
stackswitcher button:checked {
background: @accent_bg_color;
color: @accent_fg_color;
font-weight: 600;
}
/* Add some polish to the tab bar container */
#tab_bar_container {
margin: 0 6px;
}
""")
Gtk.StyleContext.add_provider_for_display(
self.get_display(),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
def _setup_sourceview_theme(self):
"""Set up GtkSourceView theme based on system color scheme."""
# Get the style manager to detect dark mode
style_manager = Adw.StyleManager.get_default()
is_dark = style_manager.get_dark()
# Get the appropriate color scheme (using classic for more subtle colors)
# You can change these to customize the color theme:
# - 'classic' / 'classic-dark' (subtle, muted colors)
# - 'Adwaita' / 'Adwaita-dark' (GNOME default)
# - 'tango' (colorful)
# - 'solarized-light' / 'solarized-dark' (popular alternative)
# - 'kate' / 'kate-dark'
style_scheme_manager = GtkSource.StyleSchemeManager.get_default()
scheme_name = 'Adwaita-dark' if is_dark else 'Adwaita'
scheme = style_scheme_manager.get_scheme(scheme_name)
if scheme:
self.response_body_sourceview.get_buffer().set_style_scheme(scheme)
# Listen for theme changes
style_manager.connect('notify::dark', self._on_theme_changed)
def _on_theme_changed(self, style_manager, param):
"""Handle system theme changes."""
is_dark = style_manager.get_dark()
style_scheme_manager = GtkSource.StyleSchemeManager.get_default()
scheme_name = 'Adwaita-dark' if is_dark else 'Adwaita'
scheme = style_scheme_manager.get_scheme(scheme_name)
if scheme:
self.response_body_sourceview.get_buffer().set_style_scheme(scheme)
# Also update request body theme
self.body_sourceview.get_buffer().set_style_scheme(scheme)
def _setup_request_body_theme(self):
"""Set up GtkSourceView theme for request body."""
style_manager = Adw.StyleManager.get_default()
is_dark = style_manager.get_dark()
style_scheme_manager = GtkSource.StyleSchemeManager.get_default()
scheme_name = 'Adwaita-dark' if is_dark else 'Adwaita'
scheme = style_scheme_manager.get_scheme(scheme_name)
if scheme:
self.body_sourceview.get_buffer().set_style_scheme(scheme)
def _on_request_body_language_changed(self, dropdown, param):
"""Handle request body language selection change."""
selected = dropdown.get_selected()
language_manager = GtkSource.LanguageManager.get_default()
buffer = self.body_sourceview.get_buffer()
if selected == 0: # RAW
buffer.set_language(None)
elif selected == 1: # JSON
language = language_manager.get_language('json')
buffer.set_language(language)
elif selected == 2: # XML
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)
# Connect change tracking to detect modifications
self.url_entry.connect("changed", self._on_request_changed)
self.method_dropdown.connect("notify::selected", self._on_request_changed)
self.body_language_dropdown.connect("notify::selected", self._on_request_changed)
# Track body changes
body_buffer = self.body_sourceview.get_buffer()
body_buffer.connect("changed", self._on_request_changed)
def _on_request_changed(self, widget, *args):
"""Track changes to mark tab as modified."""
if self.current_tab_id:
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
if current_tab:
# Check if modified
current_tab.modified = current_tab.is_modified()
# Update the current request in the tab
current_tab.request = self._build_request_from_ui()
# Update tab bar to show modified indicator
self._update_tab_bar_visibility()
def _is_empty_new_request_tab(self):
"""Check if current tab is an empty 'New Request' tab."""
if not self.current_tab_id:
return False
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
if not current_tab:
return False
# Check if it's a "New Request" (not from saved request)
if current_tab.saved_request_id:
return False
# Check if it's empty (no URL, no body, no headers)
request = self._build_request_from_ui()
is_empty = (
not request.url.strip() and
not request.body.strip() and
not request.headers
)
return is_empty
def _find_tab_by_saved_request_id(self, saved_request_id):
"""Find a tab by its saved_request_id. Returns None if not found."""
for tab in self.tab_manager.tabs:
if tab.saved_request_id == saved_request_id:
return tab
return None
def _generate_copy_name(self, base_name):
"""Generate a unique copy name like 'ReqA (copy)', 'ReqA (copy 2)', etc."""
# Check if "Name (copy)" exists
existing_names = {tab.name for tab in self.tab_manager.tabs}
copy_name = f"{base_name} (copy)"
if copy_name not in existing_names:
return copy_name
# Find the highest number used
counter = 2
while f"{base_name} (copy {counter})" in existing_names:
counter += 1
return f"{base_name} (copy {counter})"
def _switch_to_tab(self, tab_id):
"""Switch to a specific tab."""
tab = self.tab_manager.get_tab_by_id(tab_id)
if not tab:
return
# Update tab manager
self.tab_manager.set_active_tab(tab_id)
self.current_tab_id = tab_id
# Load the tab's request into UI
self._load_request_to_ui(tab.request)
# Restore response if available
if tab.response:
self._display_response(tab.response)
else:
# Clear response area
self.status_label.set_text("Ready")
self.time_label.set_text("")
buffer = self.response_headers_textview.get_buffer()
buffer.set_text("")
buffer = self.response_body_sourceview.get_buffer()
buffer.set_text("")
# Update tab bar
self._update_tab_bar_visibility()
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_scroll.set_visible(num_tabs >= 2)
# Update tab bar with buttons
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 tab buttons with close button
for tab in self.tab_manager.tabs:
# Create a box for tab label + close button
tab_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
tab_box.add_css_class("tab-button")
# Add active styling if this is the current tab
if tab.id == self.current_tab_id:
tab_box.add_css_class("tab-button-active")
# Tab label with modified indicator
tab_label = tab.name[:25] # Truncate long names
if tab.is_modified():
tab_label += "" # Add dot for unsaved changes
# Tab label button
tab_label_btn = Gtk.Button(label=tab_label)
tab_label_btn.add_css_class("flat")
tab_label_btn.add_css_class("tab-label-btn")
tab_label_btn.connect("clicked", lambda btn, tid=tab.id: self._switch_to_tab(tid))
tab_box.append(tab_label_btn)
# Close button
close_btn = Gtk.Button()
close_btn.set_icon_name("window-close-symbolic")
close_btn.add_css_class("flat")
close_btn.add_css_class("circular")
close_btn.add_css_class("tab-close-btn")
close_btn.set_tooltip_text("Close tab")
close_btn.connect("clicked", lambda btn, tid=tab.id: self._close_tab(tid))
tab_box.append(close_btn)
self.tab_bar_container.append(tab_box)
def _close_tab(self, tab_id):
"""Close a tab with unsaved change warning."""
# Update current tab state
if self.current_tab_id == 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()
# Check if tab has unsaved changes
tab = self.tab_manager.get_tab_by_id(tab_id)
if tab and tab.is_modified():
# Show warning dialog
dialog = Adw.AlertDialog()
dialog.set_heading("Close Unsaved Request?")
dialog.set_body(f"'{tab.name}' has unsaved changes. Close anyway?")
dialog.add_response("cancel", "Cancel")
dialog.add_response("close", "Close")
dialog.set_response_appearance("close", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
dialog.connect("response", lambda d, r: self._on_close_tab_dialog_response(r, tab_id))
dialog.present(self)
else:
# No unsaved changes, close directly
self._do_close_tab(tab_id)
def _on_close_tab_dialog_response(self, response, tab_id):
"""Handle close tab dialog response."""
if response == "close":
self._do_close_tab(tab_id)
def _do_close_tab(self, tab_id):
"""Actually close the tab (after confirmation if needed)."""
# Close the tab
success = self.tab_manager.close_tab(tab_id)
if success:
# If we closed the last tab, create a new one
if len(self.tab_manager.tabs) == 0:
self._create_new_tab()
else:
# Switch to the active tab (tab_manager already updated active_tab_id)
active_tab = self.tab_manager.get_active_tab()
if active_tab:
self.current_tab_id = active_tab.id
self._load_request_to_ui(active_tab.request)
if active_tab.response:
self._display_response(active_tab.response)
else:
# Clear response display
self.status_label.set_text("Ready")
self.time_label.set_text("")
buffer = self.response_headers_textview.get_buffer()
buffer.set_text("")
buffer = self.response_body_sourceview.get_buffer()
buffer.set_text("")
# Update tab bar
self._update_tab_bar_visibility()
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."""
# New tab shortcut (Ctrl+T)
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"])
# Close tab shortcut (Ctrl+W)
action = Gio.SimpleAction.new("close-tab", None)
action.connect("activate", lambda a, p: self._close_current_tab())
self.add_action(action)
self.get_application().set_accels_for_action("win.close-tab", ["<Control>w"])
# Save request shortcut (Ctrl+S)
action = Gio.SimpleAction.new("save-request", None)
action.connect("activate", lambda a, p: self.on_save_request_clicked(None))
self.add_action(action)
self.get_application().set_accels_for_action("win.save-request", ["<Control>s"])
def _close_current_tab(self):
"""Close the currently active tab."""
if self.current_tab_id:
self._close_tab(self.current_tab_id)
def _setup_method_dropdown(self):
"""Populate HTTP method dropdown."""
methods = Gtk.StringList()
methods.append("GET")
methods.append("POST")
methods.append("PUT")
methods.append("DELETE")
self.method_dropdown.set_model(methods)
self.method_dropdown.set_selected(0) # Default to GET
def _setup_request_tabs(self):
"""Create request tabs programmatically."""
# Create stack for switching between pages
self.request_stack = Gtk.Stack()
self.request_stack.set_vexpand(True)
self.request_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
self.request_stack.set_transition_duration(150)
# Create stack switcher for the tabs
request_switcher = Gtk.StackSwitcher()
request_switcher.set_stack(self.request_stack)
request_switcher.set_halign(Gtk.Align.CENTER)
request_switcher.set_margin_top(8)
request_switcher.set_margin_bottom(8)
# Add switcher and stack to container
self.request_tabs_container.append(request_switcher)
self.request_tabs_container.append(self.request_stack)
# Headers tab
headers_scroll = Gtk.ScrolledWindow()
headers_scroll.set_vexpand(True) # Expand to fill available space
headers_scroll.set_min_content_height(150) # Show ~3-4 rows minimum
self.headers_listbox = Gtk.ListBox()
self.headers_listbox.add_css_class("boxed-list")
headers_scroll.set_child(self.headers_listbox)
# Add header button container
headers_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
headers_box.set_margin_start(12)
headers_box.set_margin_end(12)
headers_box.set_margin_top(12)
headers_box.set_margin_bottom(12)
headers_box.append(headers_scroll)
self.add_header_button = Gtk.Button(label="Add Header")
self.add_header_button.connect("clicked", self.on_add_header_clicked)
headers_box.append(self.add_header_button)
self.request_stack.add_titled(headers_box, "headers", "Headers")
# Body tab with syntax highlighting
body_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
# SourceView for request body
body_scroll = Gtk.ScrolledWindow()
body_scroll.set_vexpand(True)
self.body_sourceview = GtkSource.View()
self.body_sourceview.set_editable(True)
self.body_sourceview.set_show_line_numbers(True)
self.body_sourceview.set_highlight_current_line(True)
self.body_sourceview.set_left_margin(12)
self.body_sourceview.set_right_margin(12)
self.body_sourceview.set_top_margin(12)
self.body_sourceview.set_bottom_margin(12)
# Apply same font styling as response body
css_provider = Gtk.CssProvider()
css_provider.load_from_data(b"""
textview {
font-family: "Source Code Pro";
font-size: 12pt;
line-height: 1.2;
}
""")
self.body_sourceview.get_style_context().add_provider(
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
# Set up theme for request body
self._setup_request_body_theme()
body_scroll.set_child(self.body_sourceview)
body_box.append(body_scroll)
# Bottom toolbar with language selector
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
toolbar.set_margin_start(12)
toolbar.set_margin_end(12)
toolbar.set_margin_top(6)
toolbar.set_margin_bottom(6)
# Spacer to push dropdown to the right
spacer = Gtk.Box()
spacer.set_hexpand(True)
toolbar.append(spacer)
# Language selector label and dropdown
lang_label = Gtk.Label(label="Syntax:")
toolbar.append(lang_label)
lang_list = Gtk.StringList()
lang_list.append("RAW")
lang_list.append("JSON")
lang_list.append("XML")
self.body_language_dropdown = Gtk.DropDown(model=lang_list)
self.body_language_dropdown.set_selected(0) # Default to RAW
self.body_language_dropdown.connect("notify::selected", self._on_request_body_language_changed)
toolbar.append(self.body_language_dropdown)
body_box.append(toolbar)
self.request_stack.add_titled(body_box, "body", "Body")
def _setup_response_tabs(self):
"""Create response tabs programmatically."""
# Create stack for switching between pages
self.response_stack = Gtk.Stack()
self.response_stack.set_vexpand(True)
self.response_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
self.response_stack.set_transition_duration(150)
# Create stack switcher for the tabs
response_switcher = Gtk.StackSwitcher()
response_switcher.set_stack(self.response_stack)
response_switcher.set_halign(Gtk.Align.CENTER)
response_switcher.set_margin_top(8)
response_switcher.set_margin_bottom(8)
# Add switcher and stack to container
self.response_tabs_container.append(response_switcher)
self.response_tabs_container.append(self.response_stack)
# Response Headers tab
headers_scroll = Gtk.ScrolledWindow()
headers_scroll.set_vexpand(True)
self.response_headers_textview = Gtk.TextView()
self.response_headers_textview.set_editable(False)
self.response_headers_textview.set_monospace(True)
self.response_headers_textview.set_left_margin(12)
self.response_headers_textview.set_right_margin(12)
self.response_headers_textview.set_top_margin(12)
self.response_headers_textview.set_bottom_margin(12)
headers_scroll.set_child(self.response_headers_textview)
self.response_stack.add_titled(headers_scroll, "headers", "Headers")
# Response Body tab with syntax highlighting
body_scroll = Gtk.ScrolledWindow()
body_scroll.set_vexpand(True)
self.response_body_sourceview = GtkSource.View()
self.response_body_sourceview.set_editable(False)
self.response_body_sourceview.set_show_line_numbers(True)
self.response_body_sourceview.set_highlight_current_line(True)
self.response_body_sourceview.set_left_margin(12)
self.response_body_sourceview.set_right_margin(12)
self.response_body_sourceview.set_top_margin(12)
self.response_body_sourceview.set_bottom_margin(12)
# Set up syntax highlighting with system theme colors
self._setup_sourceview_theme()
# Set font family and size
# You can customize these values:
# - font-family: Change to any font (e.g., "Monospace", "JetBrains Mono", "Fira Code")
# - font-size: Change to any size (e.g., 10pt, 12pt, 14pt, 16pt)
css_provider = Gtk.CssProvider()
css_provider.load_from_data(b"""
textview {
font-family: "Source Code Pro";
font-size: 12pt;
line-height: 1.2;
}
""")
self.response_body_sourceview.get_style_context().add_provider(
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
body_scroll.set_child(self.response_body_sourceview)
self.response_stack.add_titled(body_scroll, "body", "Body")
@Gtk.Template.Callback()
def on_send_clicked(self, button):
"""Handle Send button click."""
# Validate URL
url = self.url_entry.get_text().strip()
if not url:
self._show_toast("Please enter a URL")
return
# Build request from UI
request = self._build_request_from_ui()
# Disable send button during request
self.send_button.set_sensitive(False)
self.send_button.set_label("Sending...")
self.status_label.set_text("Sending...")
self.time_label.set_text("")
# Execute async (no threading needed!)
self.http_client.execute_request_async(
request,
self._handle_response_callback,
request # user_data
)
def _handle_response_callback(self, response, error, request):
"""Callback runs on main thread automatically."""
self._handle_response(request, response, error)
def _handle_response(self, request, response, error):
"""Handle response in main thread."""
# Re-enable send button
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(),
request=request,
response=response,
error=error
)
# Save to history
self.history_manager.add_entry(entry)
# Update UI
if response:
self._display_response(response)
else:
self._display_error(error)
# Refresh history list
self._load_history()
def _build_request_from_ui(self):
"""Build HttpRequest from UI inputs."""
method = self.method_dropdown.get_selected_item().get_string()
url = self.url_entry.get_text().strip()
# Collect headers
headers = {}
child = self.headers_listbox.get_first_child()
while child is not None:
# In GTK4, ListBox wraps children in ListBoxRow, so we need to get the actual child
if isinstance(child, Gtk.ListBoxRow):
header_row = child.get_child()
if isinstance(header_row, HeaderRow):
key, value = header_row.get_header()
if key and value:
headers[key] = value
child = child.get_next_sibling()
# Get body
buffer = self.body_sourceview.get_buffer()
body = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False)
# Get syntax selection
syntax_index = self.body_language_dropdown.get_selected()
syntax_options = ["RAW", "JSON", "XML"]
syntax = syntax_options[syntax_index]
return HttpRequest(method=method, url=url, headers=headers, body=body, syntax=syntax)
def _display_response(self, response):
"""Display response in UI."""
# Update status
status_text = f"{response.status_code} {response.status_text}"
self.status_label.set_text(status_text)
# Update time
time_text = f"{response.response_time_ms:.0f} ms"
self.time_label.set_text(time_text)
# Update response headers
buffer = self.response_headers_textview.get_buffer()
buffer.set_text(response.headers)
# Extract content-type and format body
content_type = self._extract_content_type(response.headers)
formatted_body = self._format_response_body(response.body, content_type)
# Update response body with syntax highlighting
source_buffer = self.response_body_sourceview.get_buffer()
source_buffer.set_text(formatted_body)
# Set language for syntax highlighting
language = self._get_language_from_content_type(content_type)
source_buffer.set_language(language)
def _display_error(self, error):
"""Display error in UI."""
self.status_label.set_text("Error")
self.time_label.set_text("")
# Clear response headers
buffer = self.response_headers_textview.get_buffer()
buffer.set_text("")
# Display error in body
source_buffer = self.response_body_sourceview.get_buffer()
source_buffer.set_text(error)
source_buffer.set_language(None) # No syntax highlighting for errors
# Show toast
self._show_toast(f"Request failed: {error}")
def _extract_content_type(self, headers_text):
"""Extract content-type from response headers."""
if not headers_text:
return ""
# Parse headers line by line
for line in headers_text.split('\n'):
line = line.strip()
if line.lower().startswith('content-type:'):
# Extract value after colon
return line.split(':', 1)[1].strip().lower()
return ""
def _get_language_from_content_type(self, content_type):
"""Get GtkSourceView language ID from content type."""
if not content_type:
return None
language_manager = GtkSource.LanguageManager.get_default()
# Map content types to language IDs
if 'application/json' in content_type or 'text/json' in content_type:
return language_manager.get_language('json')
elif 'application/xml' in content_type or 'text/xml' in content_type:
return language_manager.get_language('xml')
elif 'text/html' in content_type:
return language_manager.get_language('html')
elif 'application/javascript' in content_type or 'text/javascript' in content_type:
return language_manager.get_language('js')
elif 'text/css' in content_type:
return language_manager.get_language('css')
return None
def _format_response_body(self, body, content_type):
"""Format response body based on content type."""
if not body or not body.strip():
return body
try:
# JSON formatting
if 'application/json' in content_type or 'text/json' in content_type:
parsed = json.loads(body)
return json.dumps(parsed, indent=2, ensure_ascii=False)
# XML formatting
elif 'application/xml' in content_type or 'text/xml' in content_type:
dom = xml.dom.minidom.parseString(body)
return dom.toprettyxml(indent=" ")
except Exception as e:
# If formatting fails, return original body
print(f"Failed to format body: {e}")
# Return original for other content types or if formatting failed
return body
def on_add_header_clicked(self, button):
"""Add new header row."""
self._add_header_row()
def _add_header_row(self, key='', value=''):
"""Add a header row to the list."""
row = HeaderRow()
row.set_header(key, value)
row.connect('remove-requested', self._on_header_remove)
row.connect('changed', self._on_request_changed)
self.headers_listbox.append(row)
# Mark as changed if we're adding a non-empty header
if key or value:
self._on_request_changed(None)
def _on_header_remove(self, header_row):
"""Handle header row removal."""
# In GTK4, we need to remove the parent ListBoxRow, not the HeaderRow itself
parent = header_row.get_parent()
if parent:
self.headers_listbox.remove(parent)
# Mark as changed
self._on_request_changed(None)
def _load_history(self):
"""Load history from file and populate list."""
# Clear existing history items
while True:
child = self.history_listbox.get_first_child()
if child is None:
break
self.history_listbox.remove(child)
# Load and display history
entries = self.history_manager.load_history()
for entry in entries:
item = HistoryItem(entry)
item.connect('load-requested', self._on_history_load_requested, entry)
self.history_listbox.append(item)
def _on_history_load_requested(self, widget, entry):
"""Handle load request from history item."""
# Smart loading: replaces empty tabs or creates new tab if modified
self._load_request_from_entry(entry)
def _load_request_from_entry(self, entry):
"""Load request from history entry - smart loading based on current tab state."""
request = entry.request
# Generate name from method and URL
url_parts = request.url.split('/')
url_name = url_parts[-1] if url_parts else request.url
name = f"{request.method} {url_name[:30]}" if url_name else f"{request.method} Request"
# Check if current tab is an empty "New Request"
if self._is_empty_new_request_tab():
# Replace the empty tab with this request
if self.current_tab_id:
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
if current_tab:
# Update the tab
current_tab.name = name
current_tab.request = request
current_tab.saved_request_id = None # Not from saved requests
current_tab.original_request = HttpRequest(
method=request.method,
url=request.url,
headers=request.headers.copy(),
body=request.body,
syntax=getattr(request, 'syntax', 'RAW')
)
current_tab.modified = False
# Load into UI
self._load_request_to_ui(request)
self._update_tab_bar_visibility()
else:
# Current tab has changes or is not a "New Request"
# Create a new tab
self._create_new_tab(
name=name,
request=request,
saved_request_id=None
)
# Switch to headers tab
self.request_stack.set_visible_child_name("headers")
self._show_toast("Request loaded from history")
def _show_toast(self, message):
"""Show a toast notification."""
toast = Adw.Toast()
toast.set_title(message)
toast.set_timeout(3)
# Get the toast overlay (we need to add one)
# For now, just print to console
print(f"Toast: {message}")
# Project Management Methods
def _load_projects(self):
"""Load and display projects."""
# Clear existing
while child := self.projects_listbox.get_first_child():
self.projects_listbox.remove(child)
# Load and populate
projects = self.project_manager.load_projects()
for project in projects:
item = ProjectItem(project)
item.connect('edit-requested', self._on_project_edit, project)
item.connect('delete-requested', self._on_project_delete, project)
item.connect('add-request-requested', self._on_add_to_project, project)
item.connect('request-load-requested', self._on_load_request)
item.connect('request-delete-requested', self._on_delete_request, project)
self.projects_listbox.append(item)
@Gtk.Template.Callback()
def on_add_project_clicked(self, button):
"""Show dialog to create project."""
dialog = Adw.AlertDialog()
dialog.set_heading("New Project")
dialog.set_body("Enter a name for the new project:")
entry = Gtk.Entry()
entry.set_placeholder_text("Project name")
dialog.set_extra_child(entry)
dialog.add_response("cancel", "Cancel")
dialog.add_response("create", "Create")
dialog.set_response_appearance("create", Adw.ResponseAppearance.SUGGESTED)
dialog.set_default_response("create")
dialog.set_close_response("cancel")
def on_response(dlg, response):
if response == "create":
name = entry.get_text().strip()
if name:
self.project_manager.add_project(name)
self._load_projects()
dialog.connect("response", on_response)
dialog.present(self)
def _on_project_edit(self, widget, project):
"""Show edit project dialog with icon picker."""
dialog = Adw.AlertDialog()
dialog.set_heading("Edit Project")
dialog.set_body(f"Edit '{project.name}':")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
# Project name entry
name_label = Gtk.Label(label="Name:", xalign=0)
entry = Gtk.Entry()
entry.set_text(project.name)
box.append(name_label)
box.append(entry)
# Icon selector button
icon_label = Gtk.Label(label="Icon:", xalign=0)
icon_button = Gtk.Button()
icon_button.set_child(Gtk.Image.new_from_icon_name(project.icon))
icon_button.selected_icon = project.icon # Store current selection
def on_icon_button_clicked(btn):
icon_dialog = IconPickerDialog(current_icon=btn.selected_icon)
def on_icon_selected(dlg, icon_name):
btn.selected_icon = icon_name
btn.set_child(Gtk.Image.new_from_icon_name(icon_name))
icon_dialog.connect('icon-selected', on_icon_selected)
icon_dialog.present(self)
icon_button.connect('clicked', on_icon_button_clicked)
box.append(icon_label)
box.append(icon_button)
dialog.set_extra_child(box)
dialog.add_response("cancel", "Cancel")
dialog.add_response("save", "Save")
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
def on_response(dlg, response):
if response == "save":
new_name = entry.get_text().strip()
new_icon = icon_button.selected_icon
if new_name:
self.project_manager.update_project(project.id, new_name, new_icon)
self._load_projects()
dialog.connect("response", on_response)
dialog.present(self)
def _on_project_delete(self, widget, project):
"""Show delete confirmation."""
dialog = Adw.AlertDialog()
dialog.set_heading("Delete Project?")
dialog.set_body(f"Delete '{project.name}' and all its saved requests? This cannot be undone.")
dialog.add_response("cancel", "Cancel")
dialog.add_response("delete", "Delete")
dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_close_response("cancel")
def on_response(dlg, response):
if response == "delete":
self.project_manager.delete_project(project.id)
self._load_projects()
dialog.connect("response", on_response)
dialog.present(self)
@Gtk.Template.Callback()
def on_save_request_clicked(self, button):
"""Save current request to a project."""
request = self._build_request_from_ui()
if not request.url.strip():
self._show_toast("Cannot save: URL is empty")
return
projects = self.project_manager.load_projects()
if not projects:
# Show error - no projects
dialog = Adw.AlertDialog()
dialog.set_heading("No Projects")
dialog.set_body("Create a project first before saving requests.")
dialog.add_response("ok", "OK")
dialog.present(self)
return
# Create save dialog
dialog = Adw.AlertDialog()
dialog.set_heading("Save Request")
dialog.set_body("Choose a project and enter a name:")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
# Check if current tab has a saved request (for pre-filling)
current_tab = None
preselect_project_index = 0
prefill_name = ""
if self.current_tab_id:
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
if current_tab:
if current_tab.saved_request_id:
# Find which project this request belongs to
for i, p in enumerate(projects):
for req in p.requests:
if req.id == current_tab.saved_request_id:
preselect_project_index = i
prefill_name = current_tab.name
break
elif current_tab.name and current_tab.name != "New Request":
# Copy tab or named unsaved tab - prefill with tab name
prefill_name = current_tab.name
# Project dropdown
project_list = Gtk.StringList()
for p in projects:
project_list.append(p.name)
dropdown = Gtk.DropDown(model=project_list)
dropdown.set_selected(preselect_project_index) # Pre-select project
box.append(dropdown)
# Name entry
entry = Gtk.Entry()
entry.set_placeholder_text("Request name")
if prefill_name:
entry.set_text(prefill_name) # Pre-fill name
# Select all text for easy editing
entry.select_region(0, -1)
box.append(entry)
dialog.set_extra_child(box)
dialog.add_response("cancel", "Cancel")
dialog.add_response("save", "Save")
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
def on_response(dlg, response):
if response == "save":
name = entry.get_text().strip()
if name:
selected = dropdown.get_selected()
project = projects[selected]
# Check for duplicate name
existing = self.project_manager.find_request_by_name(project.id, name)
if existing:
# Show overwrite confirmation
self._show_overwrite_dialog(project, name, existing.id, request)
else:
# No duplicate, save normally
saved_request = self.project_manager.add_request(project.id, name, request)
self._load_projects()
self._show_toast(f"Saved as '{name}'")
# Clear modified flag on current tab
self._mark_tab_as_saved(saved_request.id, name, request)
dialog.connect("response", on_response)
dialog.present(self)
def _mark_tab_as_saved(self, saved_request_id, name, request):
"""Mark the current tab as saved (clear modified flag)."""
if self.current_tab_id:
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
if current_tab:
# Update tab metadata
current_tab.saved_request_id = saved_request_id
current_tab.name = name
current_tab.modified = False
# Update original_request to match saved state
current_tab.original_request = HttpRequest(
method=request.method,
url=request.url,
headers=request.headers.copy(),
body=request.body,
syntax=request.syntax
)
# Update tab bar to remove modified indicator
self._update_tab_bar_visibility()
def _show_overwrite_dialog(self, project, name, existing_request_id, request):
"""Show dialog asking if user wants to overwrite existing request."""
dialog = Adw.AlertDialog()
dialog.set_heading("Request Already Exists")
dialog.set_body(f"A request named '{name}' already exists in '{project.name}'.\n\nDo you want to overwrite it?")
dialog.add_response("cancel", "Cancel")
dialog.add_response("overwrite", "Overwrite")
dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
def on_overwrite_response(dlg, response):
if response == "overwrite":
# Update the existing request
self.project_manager.update_request(project.id, existing_request_id, name, request)
self._load_projects()
self._show_toast(f"Updated '{name}'")
# Clear modified flag on current tab
self._mark_tab_as_saved(existing_request_id, name, request)
dialog.connect("response", on_overwrite_response)
dialog.present(self)
def _on_add_to_project(self, widget, project):
"""Save current request to specific project."""
request = self._build_request_from_ui()
if not request.url.strip():
self._show_toast("Cannot save: URL is empty")
return
dialog = Adw.AlertDialog()
dialog.set_heading(f"Save to {project.name}")
dialog.set_body("Enter a name for this request:")
entry = Gtk.Entry()
entry.set_placeholder_text("Request name")
dialog.set_extra_child(entry)
dialog.add_response("cancel", "Cancel")
dialog.add_response("save", "Save")
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
def on_response(dlg, response):
if response == "save":
name = entry.get_text().strip()
if name:
# Check for duplicate name
existing = self.project_manager.find_request_by_name(project.id, name)
if existing:
# Show overwrite confirmation
self._show_overwrite_dialog(project, name, existing.id, request)
else:
# No duplicate, save normally
saved_request = self.project_manager.add_request(project.id, name, request)
self._load_projects()
self._show_toast(f"Saved as '{name}'")
# Clear modified flag on current tab
self._mark_tab_as_saved(saved_request.id, name, request)
dialog.connect("response", on_response)
dialog.present(self)
def _on_load_request(self, widget, saved_request):
"""Load saved request - smart loading based on current tab state."""
req = saved_request.request
# First, check if this request is already open in an unmodified tab
existing_tab = self._find_tab_by_saved_request_id(saved_request.id)
if existing_tab and not existing_tab.is_modified():
# Switch to the existing unmodified tab
self._switch_to_tab(existing_tab.id)
self._show_toast(f"Switched to '{saved_request.name}'")
return
# Check if there's a modified tab with this saved_request_id
# If so, this is a copy and should not be linked
is_copy = existing_tab and existing_tab.is_modified()
if is_copy:
# Generate numbered copy name
tab_name = self._generate_copy_name(saved_request.name)
link_to_saved = None
else:
tab_name = saved_request.name
link_to_saved = saved_request.id
# Check if current tab is an empty "New Request"
if self._is_empty_new_request_tab():
# Replace the empty tab with this request
if self.current_tab_id:
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
if current_tab:
# Update the tab
current_tab.name = tab_name
current_tab.request = req
current_tab.saved_request_id = link_to_saved
if is_copy:
# This is a copy - mark as unsaved
current_tab.original_request = None
current_tab.modified = True
else:
# This is linked to saved request
current_tab.original_request = HttpRequest(
method=req.method,
url=req.url,
headers=req.headers.copy(),
body=req.body,
syntax=req.syntax
)
current_tab.modified = False
# Load into UI
self._load_request_to_ui(req)
self._update_tab_bar_visibility()
else:
# Current tab has changes or is not a "New Request"
# Create a new tab
tab_id = self._create_new_tab(
name=tab_name,
request=req,
saved_request_id=link_to_saved
)
# If it's a copy, clear the original_request to mark as unsaved
if is_copy:
new_tab = self.tab_manager.get_tab_by_id(tab_id)
if new_tab:
new_tab.original_request = None
new_tab.modified = True
self._update_tab_bar_visibility()
# Switch to headers tab
self.request_stack.set_visible_child_name("headers")
def _on_delete_request(self, widget, saved_request, project):
"""Delete saved request with confirmation."""
dialog = Adw.AlertDialog()
dialog.set_heading("Delete Request?")
dialog.set_body(f"Delete '{saved_request.name}'? This cannot be undone.")
dialog.add_response("cancel", "Cancel")
dialog.add_response("delete", "Delete")
dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
def on_response(dlg, response):
if response == "delete":
self.project_manager.delete_request(project.id, saved_request.id)
self._load_projects()
dialog.connect("response", on_response)
dialog.present(self)