Add cURL export functionality with custom icon and extensible architecture

This commit is contained in:
Pavel Baksy 2026-01-12 22:27:38 +01:00
parent 8c842ee3f1
commit b9b9619425
12 changed files with 417 additions and 2 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 4 5.992188 h 1 c 0.257812 0 0.527344 -0.128907 0.71875 -0.3125 l 1.28125 -1.28125 v 6.59375 h 2 v -6.59375 l 1.28125 1.28125 c 0.191406 0.183593 0.410156 0.3125 0.71875 0.3125 h 1 v -1 c 0 -0.308594 -0.089844 -0.550782 -0.28125 -0.75 l -3.71875 -3.65625 l -3.71875 3.65625 c -0.191406 0.199218 -0.28125 0.441406 -0.28125 0.75 z m 0 0"/><path d="m 4.039062 6.957031 c -1.664062 0 -3.03125 1.371094 -3.03125 3.035157 v 1.972656 c 0 1.664062 1.367188 3.035156 3.03125 3.035156 h 7.917969 c 1.664063 0 3.03125 -1.371094 3.03125 -3.035156 v -1.972656 c 0 -1.664063 -1.367187 -3.035157 -3.03125 -3.035157 h -0.957031 v 2 h 0.957031 c 0.589844 0 1.03125 0.445313 1.03125 1.035157 v 1.972656 c 0 0.589844 -0.441406 1.035156 -1.03125 1.035156 h -7.917969 c -0.589843 0 -1.03125 -0.445312 -1.03125 -1.035156 v -1.972656 c 0 -0.589844 0.441407 -1.035157 1.03125 -1.035157 h 0.960938 v -2 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -11,3 +11,9 @@ install_data(
symbolic_dir / ('@0@-symbolic.svg').format(application_id),
install_dir: get_option('datadir') / 'icons' / symbolic_dir
)
# Install custom export icon
install_data(
symbolic_dir / 'export-symbolic.svg',
install_dir: get_option('datadir') / 'icons' / symbolic_dir
)

60
src/export-dialog.ui Normal file
View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="Adw" version="1.0"/>
<template class="ExportDialog" parent="AdwDialog">
<property name="title">Export Request</property>
<property name="content-width">600</property>
<property name="content-height">400</property>
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar">
<property name="show-title">true</property>
<child type="end">
<object class="GtkButton" id="copy_button">
<property name="label">Copy</property>
<property name="tooltip-text">Copy to Clipboard</property>
<signal name="clicked" handler="on_copy_clicked"/>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</child>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">12</property>
<property name="margin-bottom">12</property>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<property name="hexpand">true</property>
<child>
<object class="GtkTextView" id="export_textview">
<property name="editable">false</property>
<property name="monospace">true</property>
<property name="wrap-mode">none</property>
<property name="left-margin">12</property>
<property name="right-margin">12</property>
<property name="top-margin">12</property>
<property name="bottom-margin">12</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</template>
</interface>

90
src/export_dialog.py Normal file
View File

@ -0,0 +1,90 @@
# export_dialog.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
from gi.repository import Adw, Gtk, Gdk, GLib
import logging
logger = logging.getLogger(__name__)
@Gtk.Template(resource_path='/cz/bugsy/roster/export-dialog.ui')
class ExportDialog(Adw.Dialog):
"""Dialog for exporting HTTP requests in various formats."""
__gtype_name__ = 'ExportDialog'
export_textview = Gtk.Template.Child()
copy_button = Gtk.Template.Child()
def __init__(self, request, **kwargs):
"""
Initialize export dialog.
Args:
request: HttpRequest to export (should have variables substituted)
"""
super().__init__(**kwargs)
self.request = request
self._populate_export()
def _populate_export(self):
"""Generate and display the export content."""
try:
# Get cURL exporter
from .exporters import ExporterRegistry
exporter = ExporterRegistry.get_exporter('curl')
# Generate cURL command
curl_command = exporter.export(self.request)
# Display in textview
buffer = self.export_textview.get_buffer()
buffer.set_text(curl_command)
except Exception as e:
logger.error(f"Export generation failed: {e}")
buffer = self.export_textview.get_buffer()
buffer.set_text(f"Error generating export: {str(e)}")
@Gtk.Template.Callback()
def on_copy_clicked(self, button):
"""Copy export content to clipboard."""
# Get text from buffer
buffer = self.export_textview.get_buffer()
start = buffer.get_start_iter()
end = buffer.get_end_iter()
text = buffer.get_text(start, end, False)
# Copy to clipboard using Gdk.Clipboard API
display = Gdk.Display.get_default()
clipboard = display.get_clipboard()
clipboard.set(text)
# Visual feedback: temporarily change button label
original_label = button.get_label()
button.set_label("Copied!")
# Reset after 2 seconds
def reset_label():
button.set_label(original_label)
return False # Don't repeat
GLib.timeout_add(2000, reset_label)
logger.debug("Export copied to clipboard")

24
src/exporters/__init__.py Normal file
View File

@ -0,0 +1,24 @@
# exporters package
#
# 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
from .registry import ExporterRegistry
from .base_exporter import BaseExporter
from .curl_exporter import CurlExporter
__all__ = ['ExporterRegistry', 'BaseExporter', 'CurlExporter']

View File

@ -0,0 +1,50 @@
# base_exporter.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
from abc import ABC, abstractmethod
from ..models import HttpRequest
class BaseExporter(ABC):
"""Abstract base class for request exporters."""
@abstractmethod
def export(self, request: HttpRequest) -> str:
"""
Export HTTP request to target format.
Args:
request: HttpRequest with method, url, headers, body
Returns:
Formatted export string
"""
pass
@property
@abstractmethod
def format_name(self) -> str:
"""Human-readable format name (e.g., 'cURL', 'Insomnia')."""
pass
@property
@abstractmethod
def file_extension(self) -> str:
"""File extension for export (e.g., 'sh', 'json')."""
pass

View File

@ -0,0 +1,70 @@
# curl_exporter.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 shlex
from .base_exporter import BaseExporter
from ..models import HttpRequest
class CurlExporter(BaseExporter):
"""Export HTTP requests as cURL commands."""
@property
def format_name(self) -> str:
return "cURL"
@property
def file_extension(self) -> str:
return "sh"
def export(self, request: HttpRequest) -> str:
"""
Generate cURL command with proper shell escaping.
Format:
curl -X POST \
'https://api.example.com/endpoint' \
-H 'Content-Type: application/json' \
--data '{"key": "value"}'
"""
parts = ["curl"]
# Add HTTP method (skip if GET - it's default)
if request.method != "GET":
parts.append(f"-X {request.method}")
# Add URL (always quoted for safety)
parts.append(shlex.quote(request.url))
# Add headers
for key, value in request.headers.items():
header_str = f"{key}: {value}"
parts.append(f"-H {shlex.quote(header_str)}")
# Add body for POST/PUT/PATCH/DELETE methods
if request.body and request.method in ["POST", "PUT", "PATCH", "DELETE"]:
parts.append(f"--data {shlex.quote(request.body)}")
# Format as multiline with backslash continuations
return " \\\n ".join(parts)
# Auto-register on import
from .registry import ExporterRegistry
ExporterRegistry.register('curl', CurlExporter)

46
src/exporters/registry.py Normal file
View File

@ -0,0 +1,46 @@
# registry.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
from typing import Dict, List, Tuple, Type
from .base_exporter import BaseExporter
class ExporterRegistry:
"""Registry for managing export formats."""
_exporters: Dict[str, Type[BaseExporter]] = {}
@classmethod
def register(cls, format_id: str, exporter_class: Type[BaseExporter]):
"""Register an exporter class."""
cls._exporters[format_id] = exporter_class
@classmethod
def get_exporter(cls, format_id: str) -> BaseExporter:
"""Get exporter instance by format ID."""
exporter_class = cls._exporters.get(format_id)
if not exporter_class:
raise ValueError(f"Unknown export format: {format_id}")
return exporter_class()
@classmethod
def get_all_formats(cls) -> List[Tuple[str, str]]:
"""Get all registered formats as [(id, name), ...]."""
return [(format_id, cls._exporters[format_id]().format_name)
for format_id in cls._exporters.keys()]

View File

@ -32,8 +32,9 @@
<child type="start">
<object class="GtkLabel">
<property name="label">Projects</property>
<property name="margin-start">6</property>
<style>
<class name="heading"/>
<class name="title"/>
</style>
</object>
</child>
@ -96,6 +97,17 @@
</object>
</child>
<child type="start">
<object class="GtkButton" id="export_request_button">
<property name="icon-name">export-symbolic</property>
<property name="tooltip-text">Export as cURL</property>
<signal name="clicked" handler="on_export_request_clicked"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
<!-- Right side buttons -->
<child type="end">
<object class="GtkBox">

View File

@ -39,6 +39,7 @@ roster_sources = [
'constants.py',
'icon_picker_dialog.py',
'environments_dialog.py',
'export_dialog.py',
'preferences_dialog.py',
'request_tab_widget.py',
'script_executor.py',
@ -46,6 +47,16 @@ roster_sources = [
install_data(roster_sources, install_dir: moduledir)
# Install exporters submodule
exporters_sources = [
'exporters/__init__.py',
'exporters/base_exporter.py',
'exporters/curl_exporter.py',
'exporters/registry.py',
]
install_data(exporters_sources, install_dir: moduledir / 'exporters')
# Install widgets submodule
widgets_sources = [
'widgets/__init__.py',

View File

@ -6,6 +6,8 @@
<file preprocess="xml-stripblanks">preferences-dialog.ui</file>
<file preprocess="xml-stripblanks">icon-picker-dialog.ui</file>
<file preprocess="xml-stripblanks">environments-dialog.ui</file>
<file preprocess="xml-stripblanks">export-dialog.ui</file>
<file alias="icons/export-symbolic.svg">../data/icons/hicolor/symbolic/apps/export-symbolic.svg</file>
<file preprocess="xml-stripblanks">widgets/header-row.ui</file>
<file preprocess="xml-stripblanks">widgets/history-item.ui</file>
<file preprocess="xml-stripblanks">widgets/project-item.ui</file>

View File

@ -55,6 +55,7 @@ class RosterWindow(Adw.ApplicationWindow):
# Top bar widgets
save_request_button = Gtk.Template.Child()
export_request_button = Gtk.Template.Child()
new_request_button = Gtk.Template.Child()
tab_view = Gtk.Template.Child()
tab_bar = Gtk.Template.Child()
@ -336,7 +337,7 @@ class RosterWindow(Adw.ApplicationWindow):
"""Create a new tab with RequestTabWidget."""
# Create tab in tab manager
if not request:
request = HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW")
request = HttpRequest(method="GET", url="", headers=HttpRequest.default_headers(), body="", syntax="RAW")
tab = self.tab_manager.create_tab(name, request, saved_request_id, response,
project_id, selected_environment_id, scripts)
@ -1078,6 +1079,47 @@ class RosterWindow(Adw.ApplicationWindow):
dialog.connect("response", on_response)
dialog.present(self)
@Gtk.Template.Callback()
def on_export_request_clicked(self, button):
"""Handle Export button click - export request as cURL command."""
# Get current tab
page = self.tab_view.get_selected_page()
if not page:
self._show_toast("No active request")
return
widget = self.page_to_widget.get(page)
if not widget:
self._show_toast("No active request")
return
# Get request from widget (raw with {{variables}})
request = widget.get_request()
# Validate URL
if not request.url.strip():
self._show_toast("Cannot export: URL is empty")
return
# Apply variable substitution if environment is selected
substituted_request = request
if widget.selected_environment_id:
env = widget.get_selected_environment()
if env:
from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(request, env)
# Warn about undefined variables
if undefined:
logger.warning(f"Undefined variables in export: {', '.join(undefined)}")
# Show warning toast but continue with export
self._show_toast(f"Warning: {len(undefined)} undefined variable(s)")
# Show export dialog with substituted request
from .export_dialog import ExportDialog
dialog = ExportDialog(substituted_request)
dialog.present(self)
def _mark_tab_as_saved(self, saved_request_id, name, request, scripts=None):
"""Mark the current tab as saved (clear modified flag)."""
page = self.tab_view.get_selected_page()