Add Scripts feature with JavaScript postprocessing

Implements JavaScript-based postprocessing for HTTP responses using gjs.
Adds Scripts tab with preprocessing (disabled) and postprocessing (functional) sections.
This commit is contained in:
Pavel Baksy 2025-12-31 01:32:14 +01:00
parent 79fa494780
commit b923a4b83e
6 changed files with 552 additions and 8 deletions

View File

@ -41,6 +41,7 @@ roster_sources = [
'environments_dialog.py',
'preferences_dialog.py',
'request_tab_widget.py',
'script_executor.py',
]
install_data(roster_sources, install_dir: moduledir)

View File

@ -205,6 +205,7 @@ class RequestTab:
original_request: Optional[HttpRequest] = None # For change detection
project_id: Optional[str] = None # Project association for environment variables
selected_environment_id: Optional[str] = None # Selected environment for variable substitution
scripts: Optional['Scripts'] = None # Scripts for preprocessing and postprocessing
def is_modified(self) -> bool:
"""Check if current request differs from original."""
@ -231,7 +232,8 @@ class RequestTab:
'modified': self.modified,
'original_request': self.original_request.to_dict() if self.original_request else None,
'project_id': self.project_id,
'selected_environment_id': self.selected_environment_id
'selected_environment_id': self.selected_environment_id,
'scripts': self.scripts.to_dict() if self.scripts else None
}
@classmethod
@ -246,5 +248,28 @@ class RequestTab:
modified=data.get('modified', False),
original_request=HttpRequest.from_dict(data['original_request']) if data.get('original_request') else None,
project_id=data.get('project_id'),
selected_environment_id=data.get('selected_environment_id')
selected_environment_id=data.get('selected_environment_id'),
scripts=Scripts.from_dict(data['scripts']) if data.get('scripts') else None
)
@dataclass
class Scripts:
"""Scripts for request preprocessing and postprocessing."""
preprocessing: str = ""
postprocessing: str = ""
def to_dict(self):
"""Convert to dictionary for JSON serialization."""
return {
'preprocessing': self.preprocessing,
'postprocessing': self.postprocessing
}
@classmethod
def from_dict(cls, data):
"""Create instance from dictionary."""
return cls(
preprocessing=data.get('preprocessing', ''),
postprocessing=data.get('postprocessing', '')
)

View File

@ -29,7 +29,7 @@ import xml.dom.minidom
class RequestTabWidget(Gtk.Box):
"""Widget representing a single request tab's UI."""
def __init__(self, tab_id, request=None, response=None, project_id=None, selected_environment_id=None, **kwargs):
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 = tab_id
@ -39,6 +39,7 @@ class RequestTabWidget(Gtk.Box):
self.original_request = None
self.project_id = project_id
self.selected_environment_id = selected_environment_id
self.scripts = scripts
self.project_manager = None # Will be injected from window
self.environment_dropdown = None # Will be created if project_id is set
self.env_separator = None # Visual separator after environment dropdown
@ -52,6 +53,10 @@ class RequestTabWidget(Gtk.Box):
if request:
self._load_request(request)
# Load initial scripts
if scripts:
self.load_scripts(scripts)
# Setup change tracking
self._setup_change_tracking()
@ -136,6 +141,9 @@ class RequestTabWidget(Gtk.Box):
# Body tab
self._build_body_tab()
# Scripts tab
self._build_scripts_tab()
split_pane.set_start_child(request_box)
# Response Panel
@ -199,6 +207,91 @@ class RequestTabWidget(Gtk.Box):
body_scroll.set_child(self.response_body_sourceview)
self.response_stack.add_titled(body_scroll, "body", "Body")
# Script Results Panel (collapsible)
self.script_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.script_results_container.set_visible(False) # Initially hidden
# Separator
results_separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
results_separator.set_margin_top(6)
self.script_results_container.append(results_separator)
# 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_icon_name("emblem-ok-symbolic")
results_header.append(self.script_status_icon)
self.script_results_container.append(results_header)
# Output text view (scrollable)
self.script_output_scroll = Gtk.ScrolledWindow()
self.script_output_scroll.set_min_content_height(60)
self.script_output_scroll.set_max_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)
# Expander control (separator-based, shown when output > 3 lines)
self.script_expander = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self.script_expander.set_margin_start(12)
self.script_expander.set_margin_end(12)
self.script_expander.set_margin_bottom(6)
self.script_expander.set_visible(False) # Hidden by default
expander_sep1 = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
expander_sep1.set_hexpand(True)
self.script_expander.append(expander_sep1)
self.script_expander_label = Gtk.Label(label="Show full output")
self.script_expander_label.add_css_class("dim-label")
self.script_expander_label.add_css_class("caption")
self.script_expander.append(self.script_expander_label)
expander_sep2 = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
expander_sep2.set_hexpand(True)
self.script_expander.append(expander_sep2)
# Add click gesture for expander
expander_gesture = Gtk.GestureClick()
expander_gesture.connect("released", self._on_script_expander_clicked)
self.script_expander.add_controller(expander_gesture)
self.script_results_container.append(self.script_expander)
# Track expansion state
self.script_output_expanded = False
response_box.append(self.script_results_container)
# Status Bar
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
status_box.set_margin_start(12)
@ -322,6 +415,167 @@ class RequestTabWidget(Gtk.Box):
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(300)
paned.set_vexpand(True)
# Preprocessing Section (disabled for now)
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 (Coming Soon)")
preprocessing_label.set_halign(Gtk.Align.START)
preprocessing_label.add_css_class("heading")
preprocessing_label.add_css_class("dim-label")
preprocessing_section.append(preprocessing_label)
preprocessing_scroll = Gtk.ScrolledWindow()
preprocessing_scroll.set_vexpand(True)
self.preprocessing_sourceview = GtkSource.View()
self.preprocessing_sourceview.set_editable(False)
self.preprocessing_sourceview.set_sensitive(False)
self.preprocessing_sourceview.set_show_line_numbers(True)
self.preprocessing_sourceview.set_highlight_current_line(False)
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
)
preprocessing_scroll.set_child(self.preprocessing_sourceview)
preprocessing_section.append(preprocessing_scroll)
info_label = Gtk.Label(label="Preprocessing will be available in a future release")
info_label.set_halign(Gtk.Align.START)
info_label.add_css_class("dim-label")
info_label.add_css_class("caption")
preprocessing_section.append(info_label)
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.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()
@ -491,6 +745,41 @@ class RequestTabWidget(Gtk.Box):
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):
"""Display response in this tab's UI."""
self.response = response
@ -533,6 +822,67 @@ class RequestTabWidget(Gtk.Box):
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)
# Set status icon
if script_result.success:
self.script_status_icon.set_icon_name("emblem-ok-symbolic")
output_text = script_result.output
else:
self.script_status_icon.set_icon_name("dialog-error-symbolic")
output_text = script_result.error if script_result.error else "Unknown error"
# Set output text
buffer = self.script_output_textview.get_buffer()
buffer.set_text(output_text)
# Determine if expander is needed
num_lines = output_text.count('\n') + 1
needs_expander = num_lines > 3 or len(output_text) > 150
if needs_expander:
self.script_expander.set_visible(True)
self.script_expander.set_cursor_from_name("pointer")
else:
self.script_expander.set_visible(False)
# Reset expansion state
self.script_output_expanded = False
self.script_output_scroll.set_min_content_height(60)
self.script_output_scroll.set_max_content_height(60)
self.script_expander_label.set_text("Show full output")
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("")
self.script_expander.set_visible(False)
self.script_output_expanded = False
def _on_script_expander_clicked(self, gesture, n_press, x, y):
"""Toggle script output expansion."""
self.script_output_expanded = not self.script_output_expanded
if self.script_output_expanded:
# Expand: set max first to avoid assertion errors
self.script_output_scroll.set_max_content_height(300)
self.script_output_scroll.set_min_content_height(300)
self.script_expander_label.set_text("Collapse output")
else:
# Collapse: set min first to avoid assertion errors
self.script_output_scroll.set_min_content_height(60)
self.script_output_scroll.set_max_content_height(60)
self.script_expander_label.set_text("Show full output")
def _extract_content_type(self, headers_text):
"""Extract content-type from response headers."""
if not headers_text:

153
src/script_executor.py Normal file
View File

@ -0,0 +1,153 @@
# script_executor.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 subprocess
import json
import tempfile
from pathlib import Path
from typing import Tuple, Optional
from dataclasses import dataclass
@dataclass
class ScriptResult:
"""Result of script execution."""
success: bool
output: str # console.log output or error message
error: Optional[str] = None
class ScriptExecutor:
"""Executes JavaScript code using gjs (GNOME JavaScript)."""
TIMEOUT_SECONDS = 5
@staticmethod
def execute_postprocessing_script(script_code: str, response) -> ScriptResult:
"""
Execute postprocessing script with response object.
Args:
script_code: JavaScript code to execute
response: HttpResponse object
Returns:
ScriptResult with output or error
"""
if not script_code.strip():
return ScriptResult(success=True, output="")
# Create response object for JavaScript
response_obj = ScriptExecutor._create_response_object(response)
# Build complete script with context
script_with_context = f"""
const response = {json.dumps(response_obj)};
// Capture console.log output
let consoleOutput = [];
const console = {{
log: function(...args) {{
consoleOutput.push(args.map(arg => String(arg)).join(' '));
}}
}};
// User script
try {{
{script_code}
}} catch (error) {{
consoleOutput.push('Error: ' + error.message);
throw error;
}}
// Output results
print(consoleOutput.join('\\n'));
"""
# Execute with gjs
output, error, returncode = ScriptExecutor._run_gjs_script(
script_with_context,
timeout=ScriptExecutor.TIMEOUT_SECONDS
)
if returncode == 0:
return ScriptResult(success=True, output=output.strip())
else:
error_msg = error.strip() if error else "Script execution failed"
return ScriptResult(success=False, output="", error=error_msg)
@staticmethod
def _create_response_object(response) -> dict:
"""Convert HttpResponse to JavaScript-compatible dict."""
# Parse headers text into dict
headers = {}
if response.headers:
for line in response.headers.split('\n')[1:]: # Skip status line
line = line.strip()
if ':' in line:
key, value = line.split(':', 1)
headers[key.strip()] = value.strip()
return {
'body': response.body,
'headers': headers,
'statusCode': response.status_code,
'statusText': response.status_text,
'responseTime': response.response_time_ms
}
@staticmethod
def _run_gjs_script(script_code: str, timeout: int = 5) -> Tuple[str, str, int]:
"""
Run JavaScript code using gjs.
Args:
script_code: Complete JavaScript code
timeout: Execution timeout in seconds
Returns:
Tuple of (stdout, stderr, returncode)
"""
try:
# Write script to temporary file
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
f.write(script_code)
script_path = f.name
try:
# Execute with gjs
result = subprocess.run(
['gjs', script_path],
capture_output=True,
text=True,
timeout=timeout
)
return result.stdout, result.stderr, result.returncode
finally:
# Clean up temp file
Path(script_path).unlink(missing_ok=True)
except subprocess.TimeoutExpired:
return "", f"Script execution timeout ({timeout}s)", 1
except FileNotFoundError:
return "", "gjs not found. Please ensure GNOME JavaScript is installed.", 1
except Exception as e:
return "", f"Script execution error: {str(e)}", 1

View File

@ -36,7 +36,8 @@ class TabManager:
saved_request_id: Optional[str] = None,
response: Optional[HttpResponse] = None,
project_id: Optional[str] = None,
selected_environment_id: Optional[str] = None) -> RequestTab:
selected_environment_id: Optional[str] = None,
scripts=None) -> RequestTab:
"""Create a new tab and add it to the collection."""
tab_id = str(uuid.uuid4())
@ -62,7 +63,8 @@ class TabManager:
modified=False,
original_request=original_request,
project_id=project_id,
selected_environment_id=selected_environment_id
selected_environment_id=selected_environment_id,
scripts=scripts
)
self.tabs.append(tab)

View File

@ -300,18 +300,18 @@ class RosterWindow(Adw.ApplicationWindow):
return f"{base_name} (copy {counter})"
def _create_new_tab(self, name="New Request", request=None, saved_request_id=None, response=None,
project_id=None, selected_environment_id=None):
project_id=None, selected_environment_id=None, scripts=None):
"""Create a new tab with RequestTabWidget."""
# Create tab in tab manager
if not request:
request = HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW")
tab = self.tab_manager.create_tab(name, request, saved_request_id, response,
project_id, selected_environment_id)
project_id, selected_environment_id, scripts)
self.current_tab_id = tab.id
# Create RequestTabWidget for this tab
widget = RequestTabWidget(tab.id, request, response, project_id, selected_environment_id)
widget = RequestTabWidget(tab.id, request, response, project_id, selected_environment_id, scripts)
widget.original_request = tab.original_request
widget.project_manager = self.project_manager # Inject project manager
@ -435,8 +435,21 @@ class RosterWindow(Adw.ApplicationWindow):
# Update tab with response
if response:
widget.display_response(response)
# Execute postprocessing script if exists
scripts = widget.get_scripts()
if scripts and scripts.postprocessing.strip():
from .script_executor import ScriptExecutor
script_result = ScriptExecutor.execute_postprocessing_script(
scripts.postprocessing,
response
)
widget.display_script_results(script_result)
else:
widget._clear_script_results()
else:
widget.display_error(error or "Unknown error")
widget._clear_script_results()
# Save response to tab
page = self.tab_view.get_selected_page()