# 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, GObject 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, project_id=None, selected_environment_id=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 self.project_id = project_id self.selected_environment_id = selected_environment_id self.project_manager = None # Will be injected from window self.environment_dropdown = None # Will be created if project_id is set self.undefined_variables = set() # Track undefined variables for visual feedback self._update_indicators_timeout_id = None # Debounce timer for indicator updates # 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) self.url_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) self.url_box.set_margin_start(12) self.url_box.set_margin_end(12) self.url_box.set_margin_top(12) self.url_box.set_margin_bottom(12) # Environment Selector (only if project_id is set, added first) if self.project_id: self._build_environment_selector() # 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) self.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) self.url_box.append(self.url_entry) # Send Button self.send_button = Gtk.Button(label="Send") self.send_button.add_css_class("suggested-action") self.url_box.append(self.send_button) url_clamp.set_child(self.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() # Set up text tag for undefined variable highlighting buffer = self.body_sourceview.get_buffer() self.undefined_var_tag = buffer.create_tag( "undefined-variable", background="#ffc27d30" # Semi-transparent warning color ) 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) # Connect to variable indicator updates (debounced) if self.project_id: row.connect('changed', lambda r: self._schedule_indicator_update()) 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) # Setup variable indicator updates (debounced) if self.project_id: self.url_entry.connect("changed", lambda w: self._schedule_indicator_update()) body_buffer.connect("changed", lambda b: self._schedule_indicator_update()) def _on_request_changed(self, widget, *args): """Mark this tab as modified.""" if not self.original_request: return current = self.get_request() was_modified = self.modified self.modified = self._is_different_from_original(current) # Notify if modified state changed if was_modified != self.modified: self.emit('modified-changed', self.modified) # Signal for modified state changes __gsignals__ = { 'modified-changed': (GObject.SignalFlags.RUN_FIRST, None, (bool,)) } 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 def _build_environment_selector(self): """Build environment selector and add to URL box.""" if not self.project_id or not hasattr(self, 'url_box'): return # Create dropdown (compact, no label) self.environment_dropdown = Gtk.DropDown() self.environment_dropdown.set_enable_search(False) self.environment_dropdown.set_tooltip_text("Select environment for variable substitution") # Connect signal self.environment_dropdown.connect("notify::selected", self._on_environment_changed) # Add to URL box at the start (before method dropdown) self.url_box.prepend(self.environment_dropdown) # Populate if project_manager is already available if self.project_manager: self._populate_environment_dropdown() def _populate_environment_dropdown(self): """Populate environment dropdown with project's environments.""" if not self.environment_dropdown or not self.project_manager or not self.project_id: return # Get project projects = self.project_manager.load_projects() project = None for p in projects: if p.id == self.project_id: project = p break if not project: return # Build string list with "None" + environment names string_list = Gtk.StringList() string_list.append("None") # Track environment IDs (index 0 is None) self.environment_ids = [None] for env in project.environments: string_list.append(env.name) self.environment_ids.append(env.id) self.environment_dropdown.set_model(string_list) # Select current environment if self.selected_environment_id: try: index = self.environment_ids.index(self.selected_environment_id) self.environment_dropdown.set_selected(index) except ValueError: self.environment_dropdown.set_selected(0) # Default to "None" else: self.environment_dropdown.set_selected(0) # Default to "None" # Update indicators after environment is set self._update_variable_indicators() def _show_environment_selector(self): """Show environment selector (create if it doesn't exist).""" # If selector already exists, nothing to do if self.environment_dropdown: return # Create and add the selector to URL box self._build_environment_selector() # Populate if project_manager is available if self.project_manager: self._populate_environment_dropdown() def _on_environment_changed(self, dropdown, _param): """Handle environment selection change.""" if not hasattr(self, 'environment_ids'): return selected_index = dropdown.get_selected() if selected_index < len(self.environment_ids): self.selected_environment_id = self.environment_ids[selected_index] # Update visual indicators when environment changes self._update_variable_indicators() def get_selected_environment(self): """Get the currently selected environment object.""" if not self.selected_environment_id or not self.project_manager or not self.project_id: return None # Load projects projects = self.project_manager.load_projects() project = None for p in projects: if p.id == self.project_id: project = p break if not project: return None # Find environment for env in project.environments: if env.id == self.selected_environment_id: return env return None def _detect_undefined_variables(self): """Detect undefined variables in the current request. Returns set of undefined variable names.""" from .variable_substitution import VariableSubstitution # Get current request from UI request = self.get_request() # Get selected environment env = self.get_selected_environment() if not env: # No environment selected - ALL variables are undefined # Find all variables in the request all_vars = set() all_vars.update(VariableSubstitution.find_variables(request.url)) all_vars.update(VariableSubstitution.find_variables(request.body)) for key, value in request.headers.items(): all_vars.update(VariableSubstitution.find_variables(key)) all_vars.update(VariableSubstitution.find_variables(value)) return all_vars else: # Environment selected - find which variables are undefined _, undefined = VariableSubstitution.substitute_request(request, env) return undefined def _schedule_indicator_update(self): """Schedule an indicator update with debouncing.""" # Cancel existing timeout if self._update_indicators_timeout_id: GLib.source_remove(self._update_indicators_timeout_id) # Schedule new update after 500ms self._update_indicators_timeout_id = GLib.timeout_add(500, self._update_variable_indicators_timeout) def _update_variable_indicators_timeout(self): """Timeout callback for updating indicators.""" self._update_variable_indicators() self._update_indicators_timeout_id = None return False # Don't repeat def _update_variable_indicators(self): """Update visual indicators for undefined variables.""" # Detect undefined variables self.undefined_variables = self._detect_undefined_variables() # Update URL entry self._update_url_indicator() # Update headers self._update_header_indicators() # Update body self._update_body_indicators() def _update_url_indicator(self): """Update warning indicator on URL entry.""" from .variable_substitution import VariableSubstitution url_text = self.url_entry.get_text() url_vars = set(VariableSubstitution.find_variables(url_text)) undefined_in_url = url_vars & self.undefined_variables if undefined_in_url: self.url_entry.add_css_class("warning") tooltip = "Undefined variables: " + ", ".join(sorted(undefined_in_url)) self.url_entry.set_tooltip_text(tooltip) else: self.url_entry.remove_css_class("warning") self.url_entry.set_tooltip_text("") def _update_header_indicators(self): """Update warning indicators on header rows.""" from .variable_substitution import VariableSubstitution from .widgets.header_row import HeaderRow # Iterate through all header rows child = self.headers_listbox.get_first_child() while child: # HeaderRow might be a child of the ListBox row header_row = None if isinstance(child, HeaderRow): header_row = child elif hasattr(child, 'get_child'): # GTK4 ListBox wraps widgets in GtkListBoxRow actual_child = child.get_child() if isinstance(actual_child, HeaderRow): header_row = actual_child if header_row: # Check key key_text = header_row.key_entry.get_text() key_vars = set(VariableSubstitution.find_variables(key_text)) undefined_in_key = key_vars & self.undefined_variables if undefined_in_key: header_row.key_entry.add_css_class("warning") tooltip = "Undefined: " + ", ".join(sorted(undefined_in_key)) header_row.key_entry.set_tooltip_text(tooltip) else: header_row.key_entry.remove_css_class("warning") header_row.key_entry.set_tooltip_text("") # Check value value_text = header_row.value_entry.get_text() value_vars = set(VariableSubstitution.find_variables(value_text)) undefined_in_value = value_vars & self.undefined_variables if undefined_in_value: header_row.value_entry.add_css_class("warning") tooltip = "Undefined: " + ", ".join(sorted(undefined_in_value)) header_row.value_entry.set_tooltip_text(tooltip) else: header_row.value_entry.remove_css_class("warning") header_row.value_entry.set_tooltip_text("") child = child.get_next_sibling() def _update_body_indicators(self): """Update warning indicators in body text with colored background.""" import re from .variable_substitution import VariableSubstitution buffer = self.body_sourceview.get_buffer() # Get body text start_iter = buffer.get_start_iter() end_iter = buffer.get_end_iter() body_text = buffer.get_text(start_iter, end_iter, False) # Remove all existing undefined-variable tags buffer.remove_tag(self.undefined_var_tag, start_iter, end_iter) # Find all {{variable}} patterns and highlight undefined ones pattern = VariableSubstitution.VARIABLE_PATTERN for match in pattern.finditer(body_text): var_name = match.group(1) # Check if this variable is undefined if var_name in self.undefined_variables: # Get start and end positions match_start = match.start() match_end = match.end() # Create text iters at these positions start_tag_iter = buffer.get_iter_at_offset(match_start) end_tag_iter = buffer.get_iter_at_offset(match_end) # Apply the tag buffer.apply_tag(self.undefined_var_tag, start_tag_iter, end_tag_iter) # Also set tooltip for overall info body_vars = set(VariableSubstitution.find_variables(body_text)) undefined_in_body = body_vars & self.undefined_variables if undefined_in_body: tooltip = "Undefined variables: " + ", ".join(sorted(undefined_in_body)) self.body_sourceview.set_tooltip_text(tooltip) else: self.body_sourceview.set_tooltip_text("")