diff --git a/src/http_file_import_dialog.py b/src/http_file_import_dialog.py
new file mode 100644
index 0000000..1763f25
--- /dev/null
+++ b/src/http_file_import_dialog.py
@@ -0,0 +1,360 @@
+# http_file_import_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, GObject, Gio
+from typing import List
+
+from .http_file_importer import (
+ parse_http_file, build_http_request,
+ HttpFileParseResult, HttpFileRequest,
+)
+
+_METHOD_CLASS = {
+ 'GET': 'accent',
+ 'POST': 'success',
+ 'PUT': 'warning',
+ 'PATCH': 'warning',
+ 'DELETE': 'error',
+}
+
+
+class HttpFileImportDialog(Adw.Dialog):
+ """Two-step dialog: pick .http file → select requests → import."""
+
+ __gtype_name__ = 'HttpFileImportDialog'
+
+ __gsignals__ = {
+ 'import-completed': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
+ }
+
+ def __init__(self, project_manager, existing_projects):
+ super().__init__()
+ self.set_title("Import from .http File")
+ self.set_content_width(580)
+ self.set_content_height(560)
+
+ self._pm = project_manager
+ self._existing_projects = list(existing_projects)
+ self._parse_result: HttpFileParseResult | None = None
+ self._req_checkboxes: List[tuple[HttpFileRequest, Gtk.CheckButton]] = []
+
+ self._build_ui()
+
+ # ------------------------------------------------------------------ build
+
+ def _build_ui(self):
+ tv = Adw.ToolbarView()
+ tv.add_top_bar(Adw.HeaderBar())
+
+ self._stack = Gtk.Stack()
+ self._stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
+ self._stack.set_transition_duration(200)
+ self._stack.add_named(self._make_file_page(), "file")
+ self._stack.add_named(self._make_reqs_page(), "reqs")
+ tv.set_content(self._stack)
+
+ ab = Gtk.ActionBar()
+
+ self._back_btn = Gtk.Button(label="Back")
+ self._back_btn.connect("clicked", lambda _: self._show_file_page())
+ ab.pack_start(self._back_btn)
+
+ self._browse_btn = Gtk.Button(label="Choose File…")
+ self._browse_btn.add_css_class("suggested-action")
+ self._browse_btn.connect("clicked", self._on_browse)
+ ab.pack_end(self._browse_btn)
+
+ self._import_btn = Gtk.Button(label="Import")
+ self._import_btn.add_css_class("suggested-action")
+ self._import_btn.connect("clicked", self._on_import)
+ ab.pack_end(self._import_btn)
+
+ tv.add_bottom_bar(ab)
+ self.set_child(tv)
+ self._show_file_page()
+
+ def _make_file_page(self) -> Gtk.Widget:
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
+ box.set_valign(Gtk.Align.CENTER)
+ box.set_vexpand(True)
+ box.set_margin_top(32)
+ box.set_margin_bottom(24)
+ box.set_margin_start(24)
+ box.set_margin_end(24)
+
+ icon = Gtk.Image.new_from_icon_name("document-open-symbolic")
+ icon.set_pixel_size(64)
+ icon.add_css_class("dim-label")
+ box.append(icon)
+
+ title = Gtk.Label(label="Import from .http File")
+ title.add_css_class("title-2")
+ box.append(title)
+
+ subtitle = Gtk.Label(
+ label="Choose a .http or .rest file (IntelliJ HTTP Client format) to import its requests"
+ )
+ subtitle.add_css_class("dim-label")
+ subtitle.set_wrap(True)
+ subtitle.set_justify(Gtk.Justification.CENTER)
+ box.append(subtitle)
+
+ self._file_label = Gtk.Label()
+ self._file_label.add_css_class("monospace")
+ self._file_label.add_css_class("dim-label")
+ self._file_label.set_visible(False)
+ box.append(self._file_label)
+
+ self._err_label = Gtk.Label()
+ self._err_label.add_css_class("error")
+ self._err_label.set_wrap(True)
+ self._err_label.set_visible(False)
+ box.append(self._err_label)
+
+ return box
+
+ def _make_reqs_page(self) -> Gtk.Widget:
+ outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
+ outer.set_margin_top(14)
+ outer.set_margin_bottom(14)
+ outer.set_margin_start(16)
+ outer.set_margin_end(16)
+
+ # File info row
+ file_grp = Adw.PreferencesGroup()
+ file_grp.set_title("File")
+ self._file_row = Adw.ActionRow()
+ self._file_row.set_title("Path")
+ file_grp.add(self._file_row)
+ outer.append(file_grp)
+
+ # Requests header with select-all button
+ reqs_hdr = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ reqs_hdr.set_margin_top(2)
+ reqs_lbl = Gtk.Label(label="Requests")
+ reqs_lbl.add_css_class("title-4")
+ reqs_lbl.set_hexpand(True)
+ reqs_lbl.set_xalign(0)
+ reqs_hdr.append(reqs_lbl)
+ self._sel_all_btn = Gtk.Button(label="Deselect All")
+ self._sel_all_btn.add_css_class("flat")
+ self._sel_all_btn.connect("clicked", self._on_select_all)
+ reqs_hdr.append(self._sel_all_btn)
+ outer.append(reqs_hdr)
+
+ scroll = Gtk.ScrolledWindow()
+ scroll.set_vexpand(True)
+ scroll.set_min_content_height(80)
+ self._reqs_list = Gtk.ListBox()
+ self._reqs_list.add_css_class("boxed-list")
+ self._reqs_list.set_selection_mode(Gtk.SelectionMode.NONE)
+ scroll.set_child(self._reqs_list)
+ outer.append(scroll)
+
+ # Import target
+ tgt_grp = Adw.PreferencesGroup()
+ tgt_grp.set_title("Import to")
+ tgt_grp.set_margin_top(2)
+
+ new_row = Adw.ActionRow()
+ new_row.set_title("Create new project")
+ self._new_radio = Gtk.CheckButton()
+ self._new_radio.set_active(True)
+ new_row.add_prefix(self._new_radio)
+ new_row.set_activatable_widget(self._new_radio)
+ self._new_name_entry = Gtk.Entry()
+ self._new_name_entry.set_placeholder_text("Project name")
+ self._new_name_entry.set_hexpand(True)
+ self._new_name_entry.set_valign(Gtk.Align.CENTER)
+ new_row.add_suffix(self._new_name_entry)
+ tgt_grp.add(new_row)
+
+ if self._existing_projects:
+ exist_row = Adw.ActionRow()
+ exist_row.set_title("Add to existing project")
+ self._exist_radio = Gtk.CheckButton()
+ self._exist_radio.set_group(self._new_radio)
+ exist_row.add_prefix(self._exist_radio)
+ exist_row.set_activatable_widget(self._exist_radio)
+ proj_list = Gtk.StringList.new([p.name for p in self._existing_projects])
+ self._proj_dd = Gtk.DropDown(model=proj_list)
+ self._proj_dd.set_valign(Gtk.Align.CENTER)
+ self._proj_dd.set_sensitive(False)
+ exist_row.add_suffix(self._proj_dd)
+ tgt_grp.add(exist_row)
+ self._new_radio.connect("toggled", self._on_target_toggled)
+ else:
+ self._exist_radio = None
+ self._proj_dd = None
+
+ outer.append(tgt_grp)
+ return outer
+
+ # ----------------------------------------------------------- page control
+
+ def _show_file_page(self):
+ self._stack.set_visible_child_name("file")
+ self._back_btn.set_visible(False)
+ self._browse_btn.set_visible(True)
+ self._import_btn.set_visible(False)
+
+ def _show_reqs_page(self):
+ self._stack.set_visible_child_name("reqs")
+ self._back_btn.set_visible(True)
+ self._browse_btn.set_visible(False)
+ self._import_btn.set_visible(True)
+
+ # --------------------------------------------------------- event handlers
+
+ def _on_target_toggled(self, _radio):
+ is_new = self._new_radio.get_active()
+ self._new_name_entry.set_sensitive(is_new)
+ if self._proj_dd:
+ self._proj_dd.set_sensitive(not is_new)
+
+ def _on_select_all(self, _btn):
+ all_on = all(cb.get_active() for _, cb in self._req_checkboxes)
+ new_state = not all_on
+ for _, cb in self._req_checkboxes:
+ cb.set_active(new_state)
+ self._sel_all_btn.set_label("Deselect All" if new_state else "Select All")
+
+ def _on_browse(self, _btn):
+ self._err_label.set_visible(False)
+
+ chooser = Gtk.FileChooserNative(
+ title="Open HTTP File",
+ action=Gtk.FileChooserAction.OPEN,
+ accept_label="Open",
+ cancel_label="Cancel",
+ transient_for=self.get_root(),
+ )
+
+ http_filter = Gtk.FileFilter()
+ http_filter.set_name("HTTP files (*.http, *.rest)")
+ http_filter.add_pattern("*.http")
+ http_filter.add_pattern("*.rest")
+ chooser.add_filter(http_filter)
+
+ all_filter = Gtk.FileFilter()
+ all_filter.set_name("All files")
+ all_filter.add_pattern("*")
+ chooser.add_filter(all_filter)
+
+ chooser.connect("response", self._on_file_chosen)
+ chooser.show()
+ # Keep a reference so GC doesn't collect it
+ self._chooser = chooser
+
+ def _on_file_chosen(self, chooser, response):
+ if response != Gtk.ResponseType.ACCEPT:
+ return
+
+ gfile = chooser.get_file()
+ if not gfile:
+ return
+
+ path = gfile.get_path()
+
+ try:
+ content = gfile.load_contents(None)[1].decode('utf-8', errors='replace')
+ except Exception as e:
+ self._set_err(f"Could not read file: {e}")
+ return
+
+ parsed = parse_http_file(content)
+
+ if parsed.error:
+ self._set_err(parsed.error)
+ self._file_label.set_label(path or "")
+ self._file_label.set_visible(True)
+ return
+
+ self._parse_result = parsed
+ self._current_path = path or ""
+ self._populate_reqs_page(parsed, path or "")
+ self._show_reqs_page()
+
+ def _on_import(self, _btn):
+ if not self._parse_result:
+ return
+
+ selected = [req for req, cb in self._req_checkboxes if cb.get_active()]
+ if not selected:
+ return
+
+ if self._new_radio.get_active():
+ name = self._new_name_entry.get_text().strip()
+ if not name:
+ return
+ project = self._pm.add_project(name)
+ project_id = project.id
+ elif self._exist_radio and self._exist_radio.get_active() and self._proj_dd:
+ idx = self._proj_dd.get_selected()
+ if idx >= len(self._existing_projects):
+ return
+ project_id = self._existing_projects[idx].id
+ else:
+ return
+
+ for req in selected:
+ http_req = build_http_request(req)
+ self._pm.add_request(project_id, req.name, http_req)
+
+ self.emit('import-completed', project_id)
+ self.close()
+
+ # ----------------------------------------------------------------- helpers
+
+ def _set_err(self, msg: str):
+ self._err_label.set_text(msg)
+ self._err_label.set_visible(True)
+
+ def _populate_reqs_page(self, result: HttpFileParseResult, path: str):
+ self._file_row.set_subtitle(path)
+
+ while child := self._reqs_list.get_first_child():
+ self._reqs_list.remove(child)
+ self._req_checkboxes.clear()
+
+ for req in result.requests:
+ row = Adw.ActionRow()
+ row.set_title(req.name)
+ row.set_subtitle(f"{req.method} {req.url}")
+
+ badge = Gtk.Label(label=req.method)
+ badge.add_css_class("caption")
+ badge.add_css_class("monospace")
+ badge.add_css_class(_METHOD_CLASS.get(req.method, 'dim-label'))
+ badge.set_valign(Gtk.Align.CENTER)
+ row.add_suffix(badge)
+
+ cb = Gtk.CheckButton()
+ cb.set_active(True)
+ row.add_prefix(cb)
+ row.set_activatable_widget(cb)
+
+ self._reqs_list.append(row)
+ self._req_checkboxes.append((req, cb))
+
+ # Pre-fill project name from filename (stem of the path)
+ import os
+ stem = os.path.splitext(os.path.basename(path))[0]
+ self._new_name_entry.set_text(stem)
+ self._sel_all_btn.set_label("Deselect All")
diff --git a/src/http_file_importer.py b/src/http_file_importer.py
new file mode 100644
index 0000000..16bec76
--- /dev/null
+++ b/src/http_file_importer.py
@@ -0,0 +1,188 @@
+# http_file_importer.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 re
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+
+HTTP_METHODS = frozenset({
+ 'GET', 'POST', 'PUT', 'DELETE', 'PATCH',
+ 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT',
+})
+
+
+@dataclass
+class HttpFileRequest:
+ name: str
+ method: str
+ url: str
+ headers: Dict[str, str]
+ body: str
+
+
+@dataclass
+class HttpFileParseResult:
+ requests: List[HttpFileRequest] = field(default_factory=list)
+ variables: Dict[str, str] = field(default_factory=dict)
+ error: Optional[str] = None
+
+
+def parse_http_file(content: str) -> HttpFileParseResult:
+ """Parse an IntelliJ .http / .rest file into a list of requests."""
+ result = HttpFileParseResult()
+
+ if not content or not content.strip():
+ result.error = "File is empty"
+ return result
+
+ # Split file into named blocks at each "###" separator line
+ blocks: list[tuple[str, list[str]]] = []
+ current_name = ""
+ current_lines: list[str] = []
+
+ for line in content.splitlines():
+ if re.match(r'^###', line):
+ blocks.append((current_name, current_lines))
+ current_name = line[3:].strip()
+ current_lines = []
+ else:
+ current_lines.append(line)
+ blocks.append((current_name, current_lines))
+
+ for block_name, block_lines in blocks:
+ _collect_variables(block_lines, result.variables)
+ req = _parse_block(block_name, block_lines)
+ if req is not None:
+ result.requests.append(req)
+
+ if not result.requests:
+ result.error = "No HTTP requests found in this file"
+
+ return result
+
+
+def build_http_request(req: HttpFileRequest):
+ """Convert an HttpFileRequest into an HttpRequest model object."""
+ from .models import HttpRequest
+
+ body = req.body
+ syntax = 'RAW'
+ if body.lstrip().startswith('{'):
+ syntax = 'JSON'
+ elif body.lstrip().startswith('<') and not body.lstrip().startswith('< '):
+ syntax = 'XML'
+
+ return HttpRequest(
+ method=req.method,
+ url=req.url,
+ headers=req.headers,
+ body=body,
+ syntax=syntax,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+def _collect_variables(lines: list[str], variables: dict) -> None:
+ """Extract @name = value file-level variable definitions."""
+ for line in lines:
+ m = re.match(r'^@(\w+)\s*=\s*(.+)', line.strip())
+ if m:
+ variables[m.group(1)] = m.group(2).strip()
+
+
+def _parse_block(name: str, lines: list[str]) -> Optional[HttpFileRequest]:
+ """Parse one request block. Returns None if no valid request line found."""
+ # Find the request line (METHOD URL), skipping comments and @variables
+ req_idx: Optional[int] = None
+ for i, line in enumerate(lines):
+ stripped = line.strip()
+ if not stripped:
+ continue
+ if stripped.startswith('#') or stripped.startswith('//'):
+ continue
+ if stripped.startswith('@'):
+ continue
+ parts = stripped.split(None, 1)
+ if parts and parts[0].upper() in HTTP_METHODS:
+ req_idx = i
+ break
+ # Any other non-blank, non-comment line before METHOD — not a request block
+ break
+
+ if req_idx is None:
+ return None
+
+ method, url = _split_request_line(lines[req_idx].strip())
+
+ # Parse headers: lines after req_idx until blank line or response handler
+ headers: Dict[str, str] = {}
+ body_start = len(lines)
+ i = req_idx + 1
+
+ while i < len(lines):
+ line = lines[i]
+ stripped = line.strip()
+
+ if not stripped:
+ body_start = i + 1
+ break
+ if stripped.startswith('#') or stripped.startswith('//'):
+ i += 1
+ continue
+ if stripped.startswith('>'):
+ break
+ if ':' in stripped:
+ key, _, value = stripped.partition(':')
+ headers[key.strip()] = value.strip()
+ i += 1
+
+ # Parse body: everything until a response handler line or end
+ body_lines: list[str] = []
+ for line in lines[body_start:]:
+ if line.strip().startswith('>'):
+ break
+ # Skip file-include lines (< ./file.json) — not supported
+ if re.match(r'^<\s+\S', line):
+ continue
+ body_lines.append(line)
+
+ # Strip trailing blank lines from body
+ while body_lines and not body_lines[-1].strip():
+ body_lines.pop()
+
+ body = '\n'.join(body_lines)
+
+ if not name:
+ name = f"{method} {_shorten(url)}"
+
+ return HttpFileRequest(name=name, method=method, url=url, headers=headers, body=body)
+
+
+def _split_request_line(line: str) -> tuple[str, str]:
+ parts = line.split(None, 1)
+ method = parts[0].upper()
+ url = parts[1].strip() if len(parts) > 1 else ""
+ return method, url
+
+
+def _shorten(url: str, limit: int = 60) -> str:
+ return url if len(url) <= limit else url[:limit - 1] + '…'
diff --git a/src/main-window.ui b/src/main-window.ui
index e76e491..6007934 100644
--- a/src/main-window.ui
+++ b/src/main-window.ui
@@ -13,6 +13,10 @@
Import from WSDL
win.import-wsdl
+ -
+ Import from .http File
+ win.import-http-file
+
diff --git a/src/meson.build b/src/meson.build
index 65f05e9..2c002ef 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -48,6 +48,8 @@ roster_sources = [
'wsdl_import_dialog.py',
'openapi_importer.py',
'openapi_import_dialog.py',
+ 'http_file_importer.py',
+ 'http_file_import_dialog.py',
]
install_data(roster_sources, install_dir: moduledir)
diff --git a/src/window.py b/src/window.py
index c03e415..bc0e7a2 100644
--- a/src/window.py
+++ b/src/window.py
@@ -38,6 +38,7 @@ from .icon_picker_dialog import IconPickerDialog
from .environments_dialog import EnvironmentsDialog
from .wsdl_import_dialog import WsdlImportDialog
from .openapi_import_dialog import OpenApiImportDialog
+from .http_file_import_dialog import HttpFileImportDialog
from .request_tab_widget import RequestTabWidget
from .widgets.history_item import HistoryItem
from .widgets.project_item import ProjectItem
@@ -503,6 +504,10 @@ class RosterWindow(Adw.ApplicationWindow):
action.connect("activate", lambda a, p: self.on_import_wsdl_clicked(None))
self.add_action(action)
+ action = Gio.SimpleAction.new("import-http-file", None)
+ action.connect("activate", lambda a, p: self.on_import_http_file_clicked(None))
+ self.add_action(action)
+
def _on_send_clicked(self, widget):
"""Handle Send button click from a tab widget."""
# Clear previous preprocessing results
@@ -1422,6 +1427,22 @@ class RosterWindow(Adw.ApplicationWindow):
widget.original_request = None
widget.modified = True
+ def on_import_http_file_clicked(self, button):
+ """Open the .http file import dialog."""
+ projects = self.project_manager.load_projects()
+ dialog = HttpFileImportDialog(self.project_manager, projects)
+ dialog.connect('import-completed', self._on_http_file_import_completed)
+ dialog.present(self)
+
+ def _on_http_file_import_completed(self, dialog, project_id: str):
+ """Refresh sidebar after a successful .http file import."""
+ self._load_projects()
+ projects = self.project_manager.load_projects()
+ for p in projects:
+ if p.id == project_id:
+ self._show_toast(f"Imported {len(p.requests)} request(s) into '{p.name}'")
+ break
+
def on_import_wsdl_clicked(self, button):
"""Open the WSDL import dialog."""
projects = self.project_manager.load_projects()