diff --git a/data/icons/hicolor/symbolic/apps/export-symbolic.svg b/data/icons/hicolor/symbolic/apps/export-symbolic.svg
new file mode 100644
index 0000000..19e0b42
--- /dev/null
+++ b/data/icons/hicolor/symbolic/apps/export-symbolic.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/data/icons/meson.build b/data/icons/meson.build
index 2e13dd9..5f4186f 100644
--- a/data/icons/meson.build
+++ b/data/icons/meson.build
@@ -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
+)
diff --git a/src/export-dialog.ui b/src/export-dialog.ui
new file mode 100644
index 0000000..4e19d84
--- /dev/null
+++ b/src/export-dialog.ui
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+ Export Request
+ 600
+ 400
+
+
+
+
+
+
diff --git a/src/export_dialog.py b/src/export_dialog.py
new file mode 100644
index 0000000..170bf41
--- /dev/null
+++ b/src/export_dialog.py
@@ -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 .
+#
+# 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")
diff --git a/src/exporters/__init__.py b/src/exporters/__init__.py
new file mode 100644
index 0000000..75b41c7
--- /dev/null
+++ b/src/exporters/__init__.py
@@ -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 .
+#
+# 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']
diff --git a/src/exporters/base_exporter.py b/src/exporters/base_exporter.py
new file mode 100644
index 0000000..5c4b9f6
--- /dev/null
+++ b/src/exporters/base_exporter.py
@@ -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 .
+#
+# 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
diff --git a/src/exporters/curl_exporter.py b/src/exporters/curl_exporter.py
new file mode 100644
index 0000000..ab50a63
--- /dev/null
+++ b/src/exporters/curl_exporter.py
@@ -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 .
+#
+# 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)
diff --git a/src/exporters/registry.py b/src/exporters/registry.py
new file mode 100644
index 0000000..5829286
--- /dev/null
+++ b/src/exporters/registry.py
@@ -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 .
+#
+# 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()]
diff --git a/src/main-window.ui b/src/main-window.ui
index d258f3d..1ababe8 100644
--- a/src/main-window.ui
+++ b/src/main-window.ui
@@ -32,8 +32,9 @@
Projects
+ 6
@@ -96,6 +97,17 @@
+
+
+ export-symbolic
+ Export as cURL
+
+
+
+
+
diff --git a/src/meson.build b/src/meson.build
index 84aaee2..dc7ff9c 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -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',
diff --git a/src/roster.gresource.xml b/src/roster.gresource.xml
index 431cafd..d81a418 100644
--- a/src/roster.gresource.xml
+++ b/src/roster.gresource.xml
@@ -6,6 +6,8 @@
preferences-dialog.uiicon-picker-dialog.uienvironments-dialog.ui
+ export-dialog.ui
+ ../data/icons/hicolor/symbolic/apps/export-symbolic.svgwidgets/header-row.uiwidgets/history-item.uiwidgets/project-item.ui
diff --git a/src/window.py b/src/window.py
index 745eca3..2735daf 100644
--- a/src/window.py
+++ b/src/window.py
@@ -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()