roster/src/request_tab_widget.py

1626 lines
66 KiB
Python

# 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 <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import gi
gi.require_version('GtkSource', '5')
from gi.repository import Adw, Gtk, GLib, 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=HttpRequest.default_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
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)
if self.project_id:
self._build_environment_selector_inline(self.url_container)
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_clamp = Adw.Clamp(maximum_size=1000)
url_clamp.set_hexpand(True)
self.url_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
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)
self.url_entry = Gtk.Entry()
self.url_entry.set_placeholder_text("Enter URL...")
self.url_entry.set_hexpand(True)
self.url_entry.add_css_class("url-entry")
self.url_box.append(self.url_entry)
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)
# Build content panels
self.request_panel = self._create_request_panel()
self.response_panel = self._create_response_panel()
# Normal layout: horizontal split pane
self.split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
self.split_pane.set_vexpand(True)
self.split_pane.set_position(UI_PANE_REQUEST_RESPONSE_POSITION)
self.split_pane.set_shrink_start_child(False)
self.split_pane.set_shrink_end_child(False)
self.split_pane.set_resize_start_child(True)
self.split_pane.set_resize_end_child(False)
self.split_pane.set_start_child(self.request_panel)
self.split_pane.set_end_child(self.response_panel)
# Narrow layout: single panel with Request/Response toggle
self._build_narrow_layout()
self._narrow_mode = False
self.append(self.split_pane)
def _create_request_panel(self) -> Gtk.Box:
"""Create the request panel with a responsive tab switcher."""
request_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
request_panel.set_size_request(220, -1)
# Responsive tab switcher (custom, so orientation can change)
self.request_tab_switcher = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
self.request_tab_switcher.set_halign(Gtk.Align.CENTER)
self.request_tab_switcher.set_margin_top(8)
self.request_tab_switcher.set_margin_bottom(8)
self.request_tab_switcher.add_css_class("request-tab-switcher")
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)
# Build tab pages
self._build_headers_tab()
self._build_body_tab()
self._build_scripts_tab()
# Build linked toggle buttons for each page
self._request_tab_buttons = {}
first_btn = None
for page_name, label in [("headers", "Headers"), ("body", "Body"), ("scripts", "Scripts")]:
btn = Gtk.ToggleButton(label=label)
btn.add_css_class("flat")
if first_btn is None:
btn.set_active(True)
first_btn = btn
else:
btn.set_group(first_btn)
btn.connect("toggled", self._on_request_tab_toggled, page_name)
self.request_tab_switcher.append(btn)
self._request_tab_buttons[page_name] = btn
request_panel.append(self.request_tab_switcher)
request_panel.append(self.request_stack)
# Switch switcher orientation based on available width
request_panel.connect("notify::width", self._on_request_panel_width_changed)
return request_panel
def _create_response_panel(self) -> Gtk.Box:
"""Create the response panel."""
response_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
response_box.set_size_request(250, -1)
response_switcher = Gtk.StackSwitcher()
response_switcher.set_halign(Gtk.Align.START)
response_switcher.set_valign(Gtk.Align.CENTER)
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)
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)
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)
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")
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)
self._setup_sourceview_theme()
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")
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)
self.results_paned.set_visible(False)
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)
self.preprocessing_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.preprocessing_results_container.set_visible(False)
self.preprocessing_results_container.set_size_request(-1, 100)
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)
preprocessing_spacer = Gtk.Box()
preprocessing_spacer.set_hexpand(True)
preprocessing_header.append(preprocessing_spacer)
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)
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)
self.script_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.script_results_container.set_visible(False)
self.script_results_container.set_size_request(-1, 100)
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)
results_spacer = Gtk.Box()
results_spacer.set_hexpand(True)
results_header.append(results_spacer)
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)
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)
self.response_main_paned.set_end_child(self.results_paned)
response_box.append(self.response_main_paned)
bottom_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
bottom_bar.set_margin_start(6)
bottom_bar.set_margin_end(12)
bottom_bar.set_margin_top(4)
bottom_bar.set_margin_bottom(4)
bottom_bar.append(response_switcher)
spacer = Gtk.Box()
spacer.set_hexpand(True)
bottom_bar.append(spacer)
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
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)
self.size_label = Gtk.Label(label="")
status_box.append(self.size_label)
bottom_bar.append(status_box)
response_box.append(bottom_bar)
return response_box
def _build_narrow_layout(self) -> None:
"""Build the narrow single-panel layout with Request/Response toggle."""
self.narrow_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.narrow_box.set_vexpand(True)
toggle_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
toggle_bar.set_halign(Gtk.Align.CENTER)
toggle_bar.set_margin_top(6)
toggle_bar.set_margin_bottom(6)
toggle_bar.add_css_class("linked")
self.narrow_request_btn = Gtk.ToggleButton(label="Request")
self.narrow_request_btn.set_active(True)
self.narrow_response_btn = Gtk.ToggleButton(label="Response")
self.narrow_response_btn.set_group(self.narrow_request_btn)
self.narrow_request_btn.connect("toggled", self._on_narrow_panel_toggled, "request")
self.narrow_response_btn.connect("toggled", self._on_narrow_panel_toggled, "response")
toggle_bar.append(self.narrow_request_btn)
toggle_bar.append(self.narrow_response_btn)
self.narrow_box.append(toggle_bar)
self.narrow_stack = Gtk.Stack()
self.narrow_stack.set_vexpand(True)
self.narrow_stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
self.narrow_stack.set_transition_duration(200)
self.narrow_box.append(self.narrow_stack)
def set_narrow_mode(self, narrow: bool) -> None:
"""Switch between split-pane (normal) and single-panel (narrow) layout."""
if narrow == self._narrow_mode:
return
self._narrow_mode = narrow
if narrow:
self.split_pane.set_start_child(None)
self.split_pane.set_end_child(None)
self.remove(self.split_pane)
self.narrow_stack.add_named(self.request_panel, "request")
self.narrow_stack.add_named(self.response_panel, "response")
# Sync stack visible child to match current button state
# (button state persists across wide↔narrow transitions)
if self.narrow_response_btn.get_active():
self.narrow_stack.set_visible_child_name("response")
else:
self.narrow_stack.set_visible_child_name("request")
self.append(self.narrow_box)
else:
self.narrow_stack.remove(self.request_panel)
self.narrow_stack.remove(self.response_panel)
self.remove(self.narrow_box)
self.split_pane.set_start_child(self.request_panel)
self.split_pane.set_end_child(self.response_panel)
self.append(self.split_pane)
def _on_request_tab_toggled(self, button: Gtk.ToggleButton, page_name: str) -> None:
"""Handle request tab button toggle."""
if button.get_active():
self.request_stack.set_visible_child_name(page_name)
def _on_request_panel_width_changed(self, widget, pspec) -> None:
"""Switch request tab switcher to vertical when panel is narrow."""
width = widget.get_width()
if width > 0 and width < 260:
if self.request_tab_switcher.get_orientation() != Gtk.Orientation.VERTICAL:
self.request_tab_switcher.set_orientation(Gtk.Orientation.VERTICAL)
self.request_tab_switcher.set_halign(Gtk.Align.START)
self.request_tab_switcher.set_margin_start(8)
elif width >= 260:
if self.request_tab_switcher.get_orientation() != Gtk.Orientation.HORIZONTAL:
self.request_tab_switcher.set_orientation(Gtk.Orientation.HORIZONTAL)
self.request_tab_switcher.set_halign(Gtk.Align.CENTER)
self.request_tab_switcher.set_margin_start(0)
def _on_narrow_panel_toggled(self, button: Gtk.ToggleButton, panel_name: str) -> None:
"""Handle narrow mode Request/Response toggle."""
if button.get_active():
self.narrow_stack.set_visible_child_name(panel_name)
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 size
size_text = self._format_size(response.response_size_bytes)
self.size_label.set_text(size_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)
# Switch to body tab
self.response_stack.set_visible_child_name("body")
# In narrow mode, automatically show the response panel
if self._narrow_mode:
self.narrow_stack.set_visible_child_name("response")
self.narrow_response_btn.set_active(True)
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("")
self.size_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)
if self._narrow_mode:
self.narrow_stack.set_visible_child_name("response")
self.narrow_response_btn.set_active(True)
def display_script_results(self, script_result):
"""Display script execution results."""
from .script_executor import ScriptResult
if not isinstance(script_result, ScriptResult):
return
# Determine if there's meaningful content to display (before building output_text)
has_content = (
(script_result.success and script_result.output.strip()) or # Has console output
script_result.variable_updates or # Set variables
script_result.warnings or # Has warnings
not script_result.success # Has errors
)
# If no content, hide the panel and return early
if not has_content:
self._clear_script_results()
return
# Build output text for display
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}"
# Show container and parent paned
self.results_paned.set_visible(True)
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 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 visibility and 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_visible(False) # Hide parent paned when both panels are hidden
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
# Determine if there's meaningful content to display (before building output_text)
has_content = (
(preprocessing_result.success and preprocessing_result.output.strip()) or # Has console output
preprocessing_result.variable_updates or # Set variables
preprocessing_result.warnings or # Has warnings
not preprocessing_result.success # Has errors
)
# If no content, hide the panel and return early
if not has_content:
self._clear_preprocessing_results()
return
# Build output text for display
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 and there's other content to show
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"
# Show container and parent paned
self.results_paned.set_visible(True)
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 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 visibility and 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_visible(False) # Hide parent paned when both panels are hidden
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 _is_json_content_type(self, content_type):
"""Check if the content type is JSON or a JSON-based format."""
return ('application/json' in content_type
or 'text/json' in content_type
or '+json' in content_type)
def _is_xml_content_type(self, content_type):
"""Check if the content type is XML or an XML-based format."""
return ('application/xml' in content_type
or 'text/xml' in content_type
or '+xml' in content_type)
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 self._is_json_content_type(content_type):
return language_manager.get_language('json')
elif self._is_xml_content_type(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 self._is_json_content_type(content_type):
parsed = json.loads(body)
return json.dumps(parsed, indent=2, ensure_ascii=False)
elif self._is_xml_content_type(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 _format_size(self, size_bytes):
"""Format size in bytes to human-readable format (B, KB, MB, GB)."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
size_kb = size_bytes / 1024
return f"{size_kb:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
size_mb = size_bytes / (1024 * 1024)
return f"{size_mb:.1f} MB"
else:
size_gb = size_bytes / (1024 * 1024 * 1024)
return f"{size_gb:.1f} GB"
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
old_env_id = self.selected_environment_id
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:
# Previously selected environment no longer exists
self.selected_environment_id = None
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
# If the effective environment changed (e.g. was deleted), update indicators
if self.selected_environment_id != old_env_id:
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 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, callback):
"""
Get the currently selected environment object with all values (async).
This includes sensitive variable values from the keyring.
Args:
callback: Function called with the Environment object (or None)
"""
if not self.selected_environment_id or not self.project_manager or not self.project_id:
callback(None)
return
# Get environment with secrets (includes values from keyring) - async
self.project_manager.get_environment_with_secrets(
self.project_id,
self.selected_environment_id,
callback
)
def _detect_undefined_variables(self, callback):
"""Detect undefined variables in the current request (async).
Args:
callback: Function called with set of undefined variable names
"""
from .variable_substitution import VariableSubstitution
# Get current request from UI
request = self.get_request()
def on_environment_loaded(env):
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))
callback(all_vars)
else:
# Environment selected - find which variables are undefined
_, undefined = VariableSubstitution.substitute_request(request, env)
callback(undefined)
# Get selected environment asynchronously
self.get_selected_environment(on_environment_loaded)
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 (async)."""
# Only update if we have a project (variables only make sense in project context)
if not self.project_id:
return
def on_undefined_detected(undefined_vars):
# Store detected undefined variables
self.undefined_variables = undefined_vars
# Update URL entry
self._update_url_indicator()
# Update headers
self._update_header_indicators()
# Update body
self._update_body_indicators()
# Detect undefined variables asynchronously
self._detect_undefined_variables(on_undefined_detected)
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("")