# 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 typing import Optional, Set, Dict, List import logging from .models import HttpRequest, HttpResponse from .widgets.header_row import HeaderRow from .constants import ( UI_PANE_REQUEST_RESPONSE_POSITION, UI_PANE_RESPONSE_DETAILS_POSITION, UI_PANE_RESULTS_PANEL_POSITION, UI_PANE_SCRIPTS_POSITION, UI_SCRIPT_RESULTS_PANEL_HEIGHT, DEBOUNCE_VARIABLE_INDICATORS_MS, DISPLAY_VARIABLE_MAX_LENGTH, DISPLAY_VARIABLE_TRUNCATE_SUFFIX, ) import json import xml.dom.minidom logger = logging.getLogger(__name__) 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, scripts=None, **kwargs): super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) self.tab_id: str = tab_id self.request: HttpRequest = request or HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW") self.response: Optional[HttpResponse] = response self.modified: bool = False self.original_request: Optional[HttpRequest] = None self.original_scripts = None self.project_id: Optional[str] = project_id self.selected_environment_id: Optional[str] = selected_environment_id self.scripts = scripts self.project_manager = None # Will be injected from window self.environment_dropdown: Optional[Gtk.DropDown] = None self.env_separator: Optional[Gtk.Separator] = None self.undefined_variables: Set[str] = set() self._update_indicators_timeout_id: Optional[int] = None self._is_programmatically_changing_environment: bool = False # Build the UI self._build_ui() # Load initial request if request: self._load_request(request) # Load initial scripts if scripts: self.load_scripts(scripts) # Setup change tracking self._setup_change_tracking() def _build_ui(self) -> None: """Build the complete UI for this tab.""" # URL Input Section - outer container (store as instance var for dynamic updates) self.url_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) self.url_container.set_margin_start(12) self.url_container.set_margin_end(12) self.url_container.set_margin_top(12) self.url_container.set_margin_bottom(12) # Environment Selector (left-aligned, outside clamp) if self.project_id: self._build_environment_selector_inline(self.url_container) # Add visual separator/spacer after environment self.env_separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) self.env_separator.set_margin_start(12) self.env_separator.set_margin_end(12) self.url_container.append(self.env_separator) # URL bar (method, URL, send) - centered in clamp url_clamp = Adw.Clamp(maximum_size=1000) url_clamp.set_hexpand(True) self.url_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=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) 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(icon_name="media-playback-start-symbolic") self.send_button.set_tooltip_text("Send Request") self.send_button.add_css_class("suggested-action") self.url_box.append(self.send_button) url_clamp.set_child(self.url_box) self.url_container.append(url_clamp) self.append(self.url_container) # Horizontal Split: Request | Response split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) split_pane.set_vexpand(True) split_pane.set_position(UI_PANE_REQUEST_RESPONSE_POSITION) 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() # Scripts tab self._build_scripts_tab() split_pane.set_start_child(request_box) # Response Panel response_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # Stack switcher at the top response_switcher = Gtk.StackSwitcher() response_switcher.set_halign(Gtk.Align.CENTER) response_switcher.set_margin_top(8) response_switcher.set_margin_bottom(8) response_box.append(response_switcher) # Create a vertical paned for response stack and result panels self.response_main_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) self.response_main_paned.set_vexpand(True) self.response_main_paned.set_position(UI_PANE_RESPONSE_DETAILS_POSITION) self.response_main_paned.set_shrink_start_child(False) # Don't allow results panels to shrink below their minimum size self.response_main_paned.set_shrink_end_child(False) self.response_main_paned.set_resize_start_child(True) self.response_main_paned.set_resize_end_child(True) # Response Stack 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.set_stack(self.response_stack) self.response_main_paned.set_start_child(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") # Create a second paned for preprocessing and script results self.results_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) self.results_paned.set_vexpand(True) self.results_paned.set_position(UI_PANE_RESULTS_PANEL_POSITION) # Don't allow children to shrink below their minimum size self.results_paned.set_shrink_start_child(False) self.results_paned.set_shrink_end_child(False) self.results_paned.set_resize_start_child(True) self.results_paned.set_resize_end_child(True) # Preprocessing Results Panel (resizable) self.preprocessing_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) self.preprocessing_results_container.set_visible(False) # Initially hidden # Set minimum height to ensure header is always visible (header ~40px + content min 60px) self.preprocessing_results_container.set_size_request(-1, 100) # Header preprocessing_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) preprocessing_header.set_margin_start(12) preprocessing_header.set_margin_end(12) preprocessing_header.set_margin_top(6) preprocessing_header.set_margin_bottom(6) preprocessing_label = Gtk.Label(label="Preprocessing Results") preprocessing_label.add_css_class("heading") preprocessing_label.set_halign(Gtk.Align.START) preprocessing_header.append(preprocessing_label) # Spacer preprocessing_spacer = Gtk.Box() preprocessing_spacer.set_hexpand(True) preprocessing_header.append(preprocessing_spacer) # Status icon self.preprocessing_status_icon = Gtk.Image() self.preprocessing_status_icon.set_from_icon_name("object-select-symbolic") preprocessing_header.append(self.preprocessing_status_icon) self.preprocessing_results_container.append(preprocessing_header) # Output text view (scrollable, resizable) self.preprocessing_output_scroll = Gtk.ScrolledWindow() self.preprocessing_output_scroll.set_vexpand(True) self.preprocessing_output_scroll.set_min_content_height(60) self.preprocessing_output_scroll.set_margin_start(12) self.preprocessing_output_scroll.set_margin_end(12) self.preprocessing_output_scroll.set_margin_bottom(6) self.preprocessing_output_textview = Gtk.TextView() self.preprocessing_output_textview.set_editable(False) self.preprocessing_output_textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self.preprocessing_output_textview.set_monospace(True) self.preprocessing_output_textview.set_left_margin(6) self.preprocessing_output_textview.set_right_margin(6) self.preprocessing_output_textview.set_top_margin(6) self.preprocessing_output_textview.set_bottom_margin(6) self.preprocessing_output_scroll.set_child(self.preprocessing_output_textview) self.preprocessing_results_container.append(self.preprocessing_output_scroll) self.results_paned.set_start_child(self.preprocessing_results_container) # Script Results Panel (resizable) self.script_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) self.script_results_container.set_visible(False) # Initially hidden # Set minimum height to ensure header is always visible (header ~40px + content min 60px) self.script_results_container.set_size_request(-1, 100) # Header results_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) results_header.set_margin_start(12) results_header.set_margin_end(12) results_header.set_margin_top(6) results_header.set_margin_bottom(6) results_label = Gtk.Label(label="Script Output") results_label.add_css_class("heading") results_label.set_halign(Gtk.Align.START) results_header.append(results_label) # Spacer results_spacer = Gtk.Box() results_spacer.set_hexpand(True) results_header.append(results_spacer) # Status icon self.script_status_icon = Gtk.Image() self.script_status_icon.set_from_icon_name("object-select-symbolic") results_header.append(self.script_status_icon) self.script_results_container.append(results_header) # Output text view (scrollable, resizable) self.script_output_scroll = Gtk.ScrolledWindow() self.script_output_scroll.set_vexpand(True) self.script_output_scroll.set_min_content_height(60) self.script_output_scroll.set_margin_start(12) self.script_output_scroll.set_margin_end(12) self.script_output_scroll.set_margin_bottom(6) self.script_output_textview = Gtk.TextView() self.script_output_textview.set_editable(False) self.script_output_textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self.script_output_textview.set_monospace(True) self.script_output_textview.set_left_margin(6) self.script_output_textview.set_right_margin(6) self.script_output_textview.set_top_margin(6) self.script_output_textview.set_bottom_margin(6) self.script_output_scroll.set_child(self.script_output_textview) self.script_results_container.append(self.script_output_scroll) self.results_paned.set_end_child(self.script_results_container) # Set the results paned as the end child of the main response paned self.response_main_paned.set_end_child(self.results_paned) # Add the main paned to response_box response_box.append(self.response_main_paned) # 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) self.append(split_pane) def _build_headers_tab(self) -> None: """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) -> None: """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 _build_scripts_tab(self): """Build the scripts tab with preprocessing and postprocessing sections.""" scripts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) # Vertical paned to separate preprocessing and postprocessing paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) paned.set_position(UI_PANE_SCRIPTS_POSITION) paned.set_vexpand(True) # Preprocessing Section preprocessing_section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) preprocessing_section.set_margin_start(12) preprocessing_section.set_margin_end(12) preprocessing_section.set_margin_top(12) preprocessing_section.set_margin_bottom(12) preprocessing_label = Gtk.Label(label="Preprocessing") preprocessing_label.set_halign(Gtk.Align.START) preprocessing_label.add_css_class("heading") preprocessing_section.append(preprocessing_label) preprocessing_scroll = Gtk.ScrolledWindow() preprocessing_scroll.set_vexpand(True) self.preprocessing_sourceview = GtkSource.View() self.preprocessing_sourceview.set_editable(True) self.preprocessing_sourceview.set_sensitive(True) self.preprocessing_sourceview.set_show_line_numbers(True) self.preprocessing_sourceview.set_highlight_current_line(True) self.preprocessing_sourceview.set_left_margin(12) self.preprocessing_sourceview.set_right_margin(12) self.preprocessing_sourceview.set_top_margin(12) self.preprocessing_sourceview.set_bottom_margin(12) # Set JavaScript syntax highlighting lang_manager = GtkSource.LanguageManager.get_default() js_lang = lang_manager.get_language('js') if js_lang: self.preprocessing_sourceview.get_buffer().set_language(js_lang) # 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.preprocessing_sourceview.get_style_context().add_provider( css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) # Add placeholder text preprocessing_buffer = self.preprocessing_sourceview.get_buffer() placeholder_text = """// Available: request object (modifiable) // - request.method: "GET", "POST", "PUT", "DELETE" // - request.url: Full URL string // - request.headers: Object with header key-value pairs // - request.body: Request body string // // Available: roster API // - roster.getVariable(name): Get variable from selected environment // - roster.setVariable(name, value): Set/update variable // - roster.setVariables({key: value}): Batch set // - roster.project.name: Current project name // - roster.project.environments: Array of environment names // // Modify the request before sending: // request.headers['Authorization'] = 'Bearer ' + roster.getVariable('token'); """ preprocessing_buffer.set_text(placeholder_text) preprocessing_scroll.set_child(self.preprocessing_sourceview) preprocessing_section.append(preprocessing_scroll) paned.set_start_child(preprocessing_section) # Postprocessing Section (functional) postprocessing_section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) postprocessing_section.set_margin_start(12) postprocessing_section.set_margin_end(12) postprocessing_section.set_margin_top(12) postprocessing_section.set_margin_bottom(12) postprocessing_label = Gtk.Label(label="Postprocessing") postprocessing_label.set_halign(Gtk.Align.START) postprocessing_label.add_css_class("heading") postprocessing_section.append(postprocessing_label) postprocessing_scroll = Gtk.ScrolledWindow() postprocessing_scroll.set_vexpand(True) self.postprocessing_sourceview = GtkSource.View() self.postprocessing_sourceview.set_editable(True) self.postprocessing_sourceview.set_show_line_numbers(True) self.postprocessing_sourceview.set_highlight_current_line(True) self.postprocessing_sourceview.set_left_margin(12) self.postprocessing_sourceview.set_right_margin(12) self.postprocessing_sourceview.set_top_margin(12) self.postprocessing_sourceview.set_bottom_margin(12) # Set JavaScript syntax highlighting if js_lang: self.postprocessing_sourceview.get_buffer().set_language(js_lang) # Set font self.postprocessing_sourceview.get_style_context().add_provider( css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) # Set up theme self._setup_scripts_theme() # Set placeholder text buffer = self.postprocessing_sourceview.get_buffer() placeholder_text = """// Available: response object // - response.body (string) // - response.headers (object) // - response.statusCode (number) // - response.statusText (string) // - response.responseTime (number, in ms) // // Use console.log() to output results // Example: // console.log('Status:', response.statusCode); """ buffer.set_text(placeholder_text) # Connect change signal for tracking modifications buffer.connect('changed', self._on_script_changed) postprocessing_scroll.set_child(self.postprocessing_sourceview) postprocessing_section.append(postprocessing_scroll) # Toolbar toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) # Spacer spacer = Gtk.Box() spacer.set_hexpand(True) toolbar.append(spacer) # Language label lang_label = Gtk.Label(label="JavaScript") lang_label.add_css_class("dim-label") toolbar.append(lang_label) postprocessing_section.append(toolbar) paned.set_end_child(postprocessing_section) scripts_box.append(paned) self.request_stack.add_titled(scripts_box, "scripts", "Scripts") def _setup_scripts_theme(self): """Set up GtkSourceView theme for scripts based on system color scheme.""" style_manager = Adw.StyleManager.get_default() is_dark = style_manager.get_dark() scheme_manager = GtkSource.StyleSchemeManager.get_default() scheme = scheme_manager.get_scheme('Adwaita-dark' if is_dark else 'Adwaita') if scheme: self.preprocessing_sourceview.get_buffer().set_style_scheme(scheme) self.postprocessing_sourceview.get_buffer().set_style_scheme(scheme) def _on_script_changed(self, buffer): """Called when script content changes.""" # This will be used for change tracking pass 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) -> None: """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 script change tracking preprocessing_buffer = self.preprocessing_sourceview.get_buffer() preprocessing_buffer.connect("changed", self._on_request_changed) postprocessing_buffer = self.postprocessing_sourceview.get_buffer() postprocessing_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: HttpRequest) -> bool: """Check if current request differs from original.""" if not self.original_request: return False # Check if request changed request_changed = ( 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 ) # Check if scripts changed current_scripts = self.get_scripts() scripts_changed = False if self.original_scripts is None and current_scripts: # Had no scripts, now has scripts scripts_changed = (current_scripts.preprocessing.strip() != "" or current_scripts.postprocessing.strip() != "") elif self.original_scripts and current_scripts: # Compare scripts scripts_changed = ( current_scripts.preprocessing != self.original_scripts.preprocessing or current_scripts.postprocessing != self.original_scripts.postprocessing ) return request_changed or scripts_changed def _load_request(self, request: HttpRequest) -> None: """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)) # Update variable indicators after loading the request (if project is set) if self.project_id: self._update_variable_indicators() def get_request(self) -> HttpRequest: """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 get_scripts(self): """Get current scripts from UI.""" from .models import Scripts # Get preprocessing script preprocessing_buffer = self.preprocessing_sourceview.get_buffer() preprocessing = preprocessing_buffer.get_text( preprocessing_buffer.get_start_iter(), preprocessing_buffer.get_end_iter(), False ) # Get postprocessing script postprocessing_buffer = self.postprocessing_sourceview.get_buffer() postprocessing = postprocessing_buffer.get_text( postprocessing_buffer.get_start_iter(), postprocessing_buffer.get_end_iter(), False ) return Scripts(preprocessing=preprocessing, postprocessing=postprocessing) def load_scripts(self, scripts): """Load scripts into UI.""" if not scripts: return # Load preprocessing script preprocessing_buffer = self.preprocessing_sourceview.get_buffer() preprocessing_buffer.set_text(scripts.preprocessing) # Load postprocessing script postprocessing_buffer = self.postprocessing_sourceview.get_buffer() postprocessing_buffer.set_text(scripts.postprocessing) def display_response(self, response: HttpResponse) -> None: """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: str) -> None: """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 display_script_results(self, script_result): """Display script execution results.""" from .script_executor import ScriptResult if not isinstance(script_result, ScriptResult): return # Show container self.script_results_container.set_visible(True) # Adjust paned positions to make the panel visible (use idle_add to wait for proper sizing) GLib.idle_add(self._adjust_paned_for_script_results) # Set status icon if script_result.success: self.script_status_icon.set_from_icon_name("object-select-symbolic") output_text = script_result.output else: self.script_status_icon.set_from_icon_name("dialog-error-symbolic") output_text = script_result.error if script_result.error else "Unknown error" # Append variable updates to output if script_result.variable_updates: if output_text: output_text += "\n" output_text += "\n--- Variables Set ---" for var_name, var_value in script_result.variable_updates.items(): # Truncate long values for display display_value = var_value if len(var_value) <= DISPLAY_VARIABLE_MAX_LENGTH else var_value[:DISPLAY_VARIABLE_MAX_LENGTH - 3] + DISPLAY_VARIABLE_TRUNCATE_SUFFIX output_text += f"\n>>> {var_name} = '{display_value}'" # Append warnings to output if script_result.warnings: if output_text: output_text += "\n" output_text += "\n--- Warnings ---" for warning in script_result.warnings: output_text += f"\n⚠ {warning}" # Set output text buffer = self.script_output_textview.get_buffer() buffer.set_text(output_text) def _clear_script_results(self): """Clear and hide script results panel.""" self.script_results_container.set_visible(False) buffer = self.script_output_textview.get_buffer() buffer.set_text("") # Update results_paned minimum size based on remaining visible panels if self.preprocessing_results_container.get_visible(): self.results_paned.set_size_request(-1, 100) # Only preprocessing visible else: self.results_paned.set_size_request(-1, -1) # No panels visible, no minimum def _adjust_paned_for_script_results(self): """Adjust paned positions to show script results panel.""" # Ensure results_paned has proper minimum size based on visible panels if self.preprocessing_results_container.get_visible(): # Both panels visible: minimum 200px (100px each) self.results_paned.set_size_request(-1, 200) else: # Only script results visible: minimum 100px self.results_paned.set_size_request(-1, 100) # Reserve space in the main paned for results main_height = self.response_main_paned.get_height() if main_height > 200: # Set position to show ~150px of results at the bottom self.response_main_paned.set_position(main_height - UI_SCRIPT_RESULTS_PANEL_HEIGHT) # Adjust results paned to show script output (bottom panel) results_height = self.results_paned.get_height() if results_height > 150: # If preprocessing is visible, share space if self.preprocessing_results_container.get_visible(): self.results_paned.set_position(UI_PANE_RESULTS_PANEL_POSITION) # Give preprocessing minimal space # If only script results, the paned will show it automatically return False # Don't repeat def display_preprocessing_results(self, preprocessing_result): """Display preprocessing execution results.""" from .script_executor import ScriptResult if not isinstance(preprocessing_result, ScriptResult): return # Show container self.preprocessing_results_container.set_visible(True) # Adjust paned positions to make the panel visible (use idle_add to wait for proper sizing) GLib.idle_add(self._adjust_paned_for_preprocessing_results) # Set status icon if preprocessing_result.success: self.preprocessing_status_icon.set_from_icon_name("object-select-symbolic") output_text = preprocessing_result.output else: self.preprocessing_status_icon.set_from_icon_name("dialog-error-symbolic") output_text = preprocessing_result.error if preprocessing_result.error else "Unknown error" # Append variable updates to output if preprocessing_result.variable_updates: if output_text: output_text += "\n" output_text += "\n--- Variables Set ---" for var_name, var_value in preprocessing_result.variable_updates.items(): # Truncate long values for display display_value = var_value if len(var_value) <= DISPLAY_VARIABLE_MAX_LENGTH else var_value[:DISPLAY_VARIABLE_MAX_LENGTH - 3] + DISPLAY_VARIABLE_TRUNCATE_SUFFIX output_text += f"\n>>> {var_name} = '{display_value}'" # Append warnings to output if preprocessing_result.warnings: if output_text: output_text += "\n" output_text += "\n--- Warnings ---" for warning in preprocessing_result.warnings: output_text += f"\n⚠ {warning}" # Add request modification summary if successful if preprocessing_result.success and preprocessing_result.modified_request: if output_text: output_text += "\n" output_text += "\n--- Request Modified ---" output_text += "\n✓ Request was modified by preprocessing script" # Set output text buffer = self.preprocessing_output_textview.get_buffer() buffer.set_text(output_text) def _clear_preprocessing_results(self): """Clear and hide preprocessing results panel.""" self.preprocessing_results_container.set_visible(False) buffer = self.preprocessing_output_textview.get_buffer() buffer.set_text("") # Update results_paned minimum size based on remaining visible panels if self.script_results_container.get_visible(): self.results_paned.set_size_request(-1, 100) # Only script results visible else: self.results_paned.set_size_request(-1, -1) # No panels visible, no minimum def _adjust_paned_for_preprocessing_results(self): """Adjust paned positions to show preprocessing results panel.""" # Ensure results_paned has proper minimum size based on visible panels if self.script_results_container.get_visible(): # Both panels visible: minimum 200px (100px each) self.results_paned.set_size_request(-1, 200) else: # Only preprocessing visible: minimum 100px self.results_paned.set_size_request(-1, 100) # Reserve space in the main paned for results main_height = self.response_main_paned.get_height() if main_height > 200: # Set position to show ~150px of results at the bottom self.response_main_paned.set_position(main_height - UI_SCRIPT_RESULTS_PANEL_HEIGHT) # Adjust results paned to show preprocessing output (top panel) results_height = self.results_paned.get_height() if results_height > 150: # If script results is also visible, share space equally if self.script_results_container.get_visible(): self.results_paned.set_position(results_height // 2) # Split space # If only preprocessing, the paned will show it automatically return False # Don't repeat 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: logger.debug(f"Failed to format response body: {e}") return body def _build_environment_selector_inline(self, container): """Build environment selector and add to container.""" if not self.project_id: 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 handler self._env_change_handler_id = self.environment_dropdown.connect("notify::selected", self._on_environment_changed) # Add to container at the beginning (left side) container.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 # Set flag to indicate we're programmatically changing the dropdown # This prevents the signal handler from triggering indicator updates during initialization self._is_programmatically_changing_environment = True try: # 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" finally: # Always clear the flag self._is_programmatically_changing_environment = False # Note: Don't update indicators here as the request might not be loaded yet # Indicators will be updated when environment changes or request is loaded 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 container if hasattr(self, 'url_container'): self._build_environment_selector_inline(self.url_container) # Add separator after environment dropdown (insert before the url_clamp) self.env_separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) self.env_separator.set_margin_start(12) self.env_separator.set_margin_end(12) # Insert separator as second child (after environment dropdown) first_child = self.url_container.get_first_child() if first_child: self.url_container.insert_child_after(self.env_separator, first_child) # Populate if project_manager is available if self.project_manager: self._populate_environment_dropdown() # Update indicators after environment selector is shown self._update_variable_indicators() def _on_environment_changed(self, dropdown, _param): """Handle environment selection change.""" # If we're programmatically changing the environment during population, skip this if self._is_programmatically_changing_environment: return if not hasattr(self, 'environment_ids'): logger.warning("Environment changed but environment_ids not initialized") return selected_index = dropdown.get_selected() if selected_index < len(self.environment_ids): self.selected_environment_id = self.environment_ids[selected_index] else: logger.warning(f"Environment index {selected_index} out of range (max {len(self.environment_ids)-1})") self.selected_environment_id = None # Always 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(DEBOUNCE_VARIABLE_INDICATORS_MS, 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.""" # Only update if we have a project (variables only make sense in project context) if not self.project_id: return # 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("")