diff --git a/src/main-window.ui b/src/main-window.ui
index f702db5..91bc8d3 100644
--- a/src/main-window.ui
+++ b/src/main-window.ui
@@ -92,15 +92,6 @@
diff --git a/src/meson.build b/src/meson.build
index 0de6bb2..39ad5ec 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -38,6 +38,7 @@ roster_sources = [
'constants.py',
'icon_picker_dialog.py',
'preferences_dialog.py',
+ 'request_tab_widget.py',
]
install_data(roster_sources, install_dir: moduledir)
diff --git a/src/request_tab_widget.py b/src/request_tab_widget.py
new file mode 100644
index 0000000..0e8bc00
--- /dev/null
+++ b/src/request_tab_widget.py
@@ -0,0 +1,556 @@
+# request_tab_widget.py
+#
+# Copyright 2025 Pavel Baksy
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import gi
+gi.require_version('GtkSource', '5')
+from gi.repository import Adw, Gtk, GLib, GtkSource
+from .models import HttpRequest, HttpResponse
+from .widgets.header_row import HeaderRow
+import json
+import xml.dom.minidom
+
+
+class RequestTabWidget(Gtk.Box):
+ """Widget representing a single request tab's UI."""
+
+ def __init__(self, tab_id, request=None, response=None, **kwargs):
+ super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
+
+ self.tab_id = tab_id
+ self.request = request or HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW")
+ self.response = response
+ self.modified = False
+ self.original_request = None
+
+ # Build the UI
+ self._build_ui()
+
+ # Load initial request
+ if request:
+ self._load_request(request)
+
+ # Setup change tracking
+ self._setup_change_tracking()
+
+ def _build_ui(self):
+ """Build the complete UI for this tab."""
+
+ # URL Input Section
+ url_clamp = Adw.Clamp(maximum_size=1000)
+ url_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
+ url_box.set_margin_start(12)
+ url_box.set_margin_end(12)
+ url_box.set_margin_top(12)
+ url_box.set_margin_bottom(12)
+
+ # Method Dropdown
+ self.method_dropdown = Gtk.DropDown()
+ methods = Gtk.StringList()
+ for method in ["GET", "POST", "PUT", "DELETE"]:
+ methods.append(method)
+ self.method_dropdown.set_model(methods)
+ self.method_dropdown.set_selected(0)
+ self.method_dropdown.set_enable_search(False)
+ url_box.append(self.method_dropdown)
+
+ # URL Entry
+ self.url_entry = Gtk.Entry()
+ self.url_entry.set_placeholder_text("Enter URL...")
+ self.url_entry.set_hexpand(True)
+ url_box.append(self.url_entry)
+
+ # Send Button
+ self.send_button = Gtk.Button(label="Send")
+ self.send_button.add_css_class("suggested-action")
+ url_box.append(self.send_button)
+
+ url_clamp.set_child(url_box)
+ self.append(url_clamp)
+
+ # Vertical Paned: Main Content | History Panel
+ vpane = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
+ vpane.set_vexpand(True)
+ vpane.set_position(600)
+ vpane.set_shrink_start_child(False)
+ vpane.set_shrink_end_child(True)
+ vpane.set_resize_start_child(True)
+ vpane.set_resize_end_child(True)
+
+ # Horizontal Split: Request | Response
+ split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
+ split_pane.set_position(600)
+ split_pane.set_shrink_start_child(False)
+ split_pane.set_shrink_end_child(False)
+ split_pane.set_resize_start_child(True)
+ split_pane.set_resize_end_child(True)
+
+ # Request Panel
+ request_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ 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)
+
+ 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)
+
+ request_box.append(request_switcher)
+ request_box.append(self.request_stack)
+
+ # Headers tab
+ self._build_headers_tab()
+
+ # Body tab
+ self._build_body_tab()
+
+ split_pane.set_start_child(request_box)
+
+ # Response Panel
+ response_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ 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)
+
+ 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)
+
+ response_box.append(response_switcher)
+ response_box.append(self.response_stack)
+
+ # Response headers
+ 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
+ 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 theme
+ self._setup_sourceview_theme()
+
+ # Set font
+ 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")
+
+ # Status Bar
+ status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
+ status_box.set_margin_start(12)
+ status_box.set_margin_end(12)
+ status_box.set_margin_top(6)
+ status_box.set_margin_bottom(6)
+
+ self.status_label = Gtk.Label(label="Ready")
+ self.status_label.add_css_class("heading")
+ status_box.append(self.status_label)
+
+ self.time_label = Gtk.Label(label="")
+ status_box.append(self.time_label)
+
+ # Spacer
+ spacer = Gtk.Box()
+ spacer.set_hexpand(True)
+ status_box.append(spacer)
+
+ response_box.append(status_box)
+
+ split_pane.set_end_child(response_box)
+
+ vpane.set_start_child(split_pane)
+
+ # History Panel (placeholder for now)
+ history_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ history_label = Gtk.Label(label="Request History")
+ history_label.add_css_class("heading")
+ history_label.set_margin_start(12)
+ history_label.set_margin_end(12)
+ history_label.set_margin_top(6)
+ history_label.set_xalign(0)
+ history_box.append(history_label)
+
+ vpane.set_end_child(history_box)
+
+ self.append(vpane)
+
+ def _build_headers_tab(self):
+ """Build the headers tab."""
+ headers_scroll = Gtk.ScrolledWindow()
+ headers_scroll.set_vexpand(True)
+ headers_scroll.set_min_content_height(150)
+
+ self.headers_listbox = Gtk.ListBox()
+ self.headers_listbox.add_css_class("boxed-list")
+ headers_scroll.set_child(self.headers_listbox)
+
+ 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)
+
+ add_header_button = Gtk.Button(label="Add Header")
+ add_header_button.connect("clicked", self._on_add_header_clicked)
+ headers_box.append(add_header_button)
+
+ self.request_stack.add_titled(headers_box, "headers", "Headers")
+
+ # Add initial empty header row
+ self._add_header_row()
+
+ def _build_body_tab(self):
+ """Build the body tab."""
+ body_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+
+ 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)
+
+ # Set font
+ 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
+ self._setup_request_body_theme()
+
+ body_scroll.set_child(self.body_sourceview)
+ body_box.append(body_scroll)
+
+ # Bottom toolbar
+ 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
+ spacer = Gtk.Box()
+ spacer.set_hexpand(True)
+ toolbar.append(spacer)
+
+ # Language selector
+ lang_label = Gtk.Label(label="Syntax:")
+ toolbar.append(lang_label)
+
+ lang_list = Gtk.StringList()
+ for lang in ["RAW", "JSON", "XML"]:
+ lang_list.append(lang)
+
+ self.body_language_dropdown = Gtk.DropDown(model=lang_list)
+ self.body_language_dropdown.set_selected(0)
+ 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_sourceview_theme(self):
+ """Set up GtkSourceView theme based on system color scheme."""
+ 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.response_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 _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)
+
+ if key or value:
+ self._on_request_changed(None)
+
+ def _on_header_remove(self, header_row):
+ """Handle header row removal."""
+ parent = header_row.get_parent()
+ if parent:
+ self.headers_listbox.remove(parent)
+ self._on_request_changed(None)
+
+ def _setup_change_tracking(self):
+ """Setup change tracking for this tab."""
+ 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)
+
+ body_buffer = self.body_sourceview.get_buffer()
+ body_buffer.connect("changed", self._on_request_changed)
+
+ def _on_request_changed(self, widget, *args):
+ """Mark this tab as modified."""
+ if not self.original_request:
+ return
+
+ current = self.get_request()
+ self.modified = self._is_different_from_original(current)
+
+ def _is_different_from_original(self, request):
+ """Check if current request differs from original."""
+ if not self.original_request:
+ return False
+
+ return (
+ request.method != self.original_request.method or
+ request.url != self.original_request.url or
+ request.body != self.original_request.body or
+ request.headers != self.original_request.headers or
+ request.syntax != self.original_request.syntax
+ )
+
+ def _load_request(self, request):
+ """Load a request into this tab's 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 get_request(self):
+ """Build and return HttpRequest from current UI state."""
+ 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:
+ 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
+ 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 this tab's UI."""
+ self.response = response
+
+ # 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
+ 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 this tab's 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)
+
+ def _extract_content_type(self, headers_text):
+ """Extract content-type from response headers."""
+ if not headers_text:
+ return ""
+
+ for line in headers_text.split('\n'):
+ line = line.strip()
+ if line.lower().startswith('content-type:'):
+ 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()
+
+ 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:
+ 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)
+ 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:
+ print(f"Failed to format body: {e}")
+
+ return body