diff --git a/src/meson.build b/src/meson.build index 6d5aebb..84aaee2 100644 --- a/src/meson.build +++ b/src/meson.build @@ -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) diff --git a/src/models.py b/src/models.py index 3c75f59..6638097 100644 --- a/src/models.py +++ b/src/models.py @@ -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', '') ) diff --git a/src/request_tab_widget.py b/src/request_tab_widget.py index 33c9d07..8ccb361 100644 --- a/src/request_tab_widget.py +++ b/src/request_tab_widget.py @@ -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: diff --git a/src/script_executor.py b/src/script_executor.py new file mode 100644 index 0000000..8be1200 --- /dev/null +++ b/src/script_executor.py @@ -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 . +# +# 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 diff --git a/src/tab_manager.py b/src/tab_manager.py index 211797d..a445dee 100644 --- a/src/tab_manager.py +++ b/src/tab_manager.py @@ -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) diff --git a/src/window.py b/src/window.py index 91b7064..eafd06f 100644 --- a/src/window.py +++ b/src/window.py @@ -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()