Compare commits
No commits in common. "master" and "v0.9.2" have entirely different histories.
1
.gitignore
vendored
1
.gitignore
vendored
@ -25,4 +25,3 @@ __pycache__/
|
|||||||
# Flatpak
|
# Flatpak
|
||||||
.flatpak/
|
.flatpak/
|
||||||
repo/
|
repo/
|
||||||
*.local.json
|
|
||||||
|
|||||||
@ -50,30 +50,6 @@
|
|||||||
</screenshots>
|
</screenshots>
|
||||||
|
|
||||||
<releases>
|
<releases>
|
||||||
<release version="0.10.0" date="2026-05-21">
|
|
||||||
<description translate="no">
|
|
||||||
<p>Version 0.10.0 release</p>
|
|
||||||
<ul>
|
|
||||||
<li>Responsive layout: sidebar collapses to an overlay panel on narrow windows</li>
|
|
||||||
<li>Narrow mode: single-panel view with Request/Response toggle buttons when sidebar is hidden</li>
|
|
||||||
<li>Adaptive request panel: Headers/Body/Scripts tabs stack vertically when the panel is narrow, with a hard minimum width</li>
|
|
||||||
<li>Import requests from .http files</li>
|
|
||||||
<li>Sidebar hamburger menu consolidating all actions (Add Project, Import, Preferences, Keyboard Shortcuts, About)</li>
|
|
||||||
<li>Method chips (GET/POST/PUT/DELETE color badges) in sidebar request list</li>
|
|
||||||
<li>Fix header bar clipping and window controls placement on narrow windows</li>
|
|
||||||
<li>Set minimum window width to prevent layout overflow</li>
|
|
||||||
</ul>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
<release version="0.9.3" date="2026-05-19">
|
|
||||||
<description translate="no">
|
|
||||||
<p>Version 0.9.3 release</p>
|
|
||||||
<ul>
|
|
||||||
<li>Fix WSDL import: generated SOAP body now correctly includes wrapper elements and proper namespace prefixes for each type (fixes WCF/DataContract services)</li>
|
|
||||||
<li>Fix window controls disappearing when moved to left side via GNOME Tweaks</li>
|
|
||||||
</ul>
|
|
||||||
</description>
|
|
||||||
</release>
|
|
||||||
<release version="0.9.2" date="2026-05-13">
|
<release version="0.9.2" date="2026-05-13">
|
||||||
<description translate="no">
|
<description translate="no">
|
||||||
<p>Version 0.9.2 release</p>
|
<p>Version 0.9.2 release</p>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
project('roster',
|
project('roster',
|
||||||
version: '0.10.0',
|
version: '0.9.2',
|
||||||
meson_version: '>= 1.0.0',
|
meson_version: '>= 1.0.0',
|
||||||
default_options: [ 'warning_level=2', 'werror=false', ],
|
default_options: [ 'warning_level=2', 'werror=false', ],
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,360 +0,0 @@
|
|||||||
# 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 <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# 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")
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
# 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 <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# 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] + '…'
|
|
||||||
@ -3,13 +3,7 @@
|
|||||||
<requires lib="gtk" version="4.0"/>
|
<requires lib="gtk" version="4.0"/>
|
||||||
<requires lib="Adw" version="1.0"/>
|
<requires lib="Adw" version="1.0"/>
|
||||||
|
|
||||||
<menu id="sidebar_menu">
|
<menu id="import_menu">
|
||||||
<section>
|
|
||||||
<item>
|
|
||||||
<attribute name="label">Add Project</attribute>
|
|
||||||
<attribute name="action">win.add-project</attribute>
|
|
||||||
</item>
|
|
||||||
</section>
|
|
||||||
<section>
|
<section>
|
||||||
<item>
|
<item>
|
||||||
<attribute name="label">Import from OpenAPI / Swagger</attribute>
|
<attribute name="label">Import from OpenAPI / Swagger</attribute>
|
||||||
@ -19,153 +13,131 @@
|
|||||||
<attribute name="label">Import from WSDL</attribute>
|
<attribute name="label">Import from WSDL</attribute>
|
||||||
<attribute name="action">win.import-wsdl</attribute>
|
<attribute name="action">win.import-wsdl</attribute>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
|
||||||
<attribute name="label">Import from .http File</attribute>
|
|
||||||
<attribute name="action">win.import-http-file</attribute>
|
|
||||||
</item>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<item>
|
|
||||||
<attribute name="label" translatable="yes">_Preferences</attribute>
|
|
||||||
<attribute name="action">app.preferences</attribute>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<attribute name="label" translatable="yes">_Keyboard Shortcuts</attribute>
|
|
||||||
<attribute name="action">app.shortcuts</attribute>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<attribute name="label" translatable="yes">_About Roster</attribute>
|
|
||||||
<attribute name="action">app.about</attribute>
|
|
||||||
</item>
|
|
||||||
</section>
|
</section>
|
||||||
</menu>
|
</menu>
|
||||||
|
|
||||||
<template class="RosterWindow" parent="AdwApplicationWindow">
|
<template class="RosterWindow" parent="AdwApplicationWindow">
|
||||||
<property name="default-width">1200</property>
|
<property name="default-width">1200</property>
|
||||||
<property name="default-height">800</property>
|
<property name="default-height">800</property>
|
||||||
<property name="width-request">470</property>
|
|
||||||
<property name="content">
|
<property name="content">
|
||||||
<object class="AdwToastOverlay" id="toast_overlay">
|
<object class="AdwToastOverlay" id="toast_overlay">
|
||||||
<property name="child">
|
<property name="child">
|
||||||
<object class="AdwOverlaySplitView" id="split_view">
|
<object class="GtkPaned" id="main_pane">
|
||||||
<property name="min-sidebar-width">200</property>
|
<property name="orientation">horizontal</property>
|
||||||
<property name="max-sidebar-width">320</property>
|
<property name="position">180</property>
|
||||||
|
<property name="shrink-start-child">False</property>
|
||||||
|
<property name="resize-start-child">True</property>
|
||||||
|
<property name="shrink-end-child">False</property>
|
||||||
|
<property name="resize-end-child">True</property>
|
||||||
|
<property name="wide-handle">False</property>
|
||||||
|
|
||||||
<!-- LEFT: Sidebar Panel with AdwToolbarView -->
|
<!-- LEFT: Sidebar Panel with AdwToolbarView -->
|
||||||
<property name="sidebar">
|
<property name="start-child">
|
||||||
<object class="AdwToolbarView">
|
<object class="AdwToolbarView">
|
||||||
|
<property name="width-request">200</property>
|
||||||
|
|
||||||
<!-- Sidebar Header Bar -->
|
<!-- Sidebar Header Bar -->
|
||||||
<child type="top">
|
<child type="top">
|
||||||
<object class="AdwHeaderBar">
|
<object class="AdwHeaderBar">
|
||||||
<property name="show-end-title-buttons">False</property>
|
<property name="show-title">False</property>
|
||||||
<property name="show-start-title-buttons">False</property>
|
<property name="show-end-title-buttons">False</property>
|
||||||
<property name="title-widget">
|
<property name="show-start-title-buttons">False</property>
|
||||||
<object class="GtkLabel">
|
<child type="start">
|
||||||
<property name="label">Projects</property>
|
<object class="GtkLabel">
|
||||||
|
<property name="label">Projects</property>
|
||||||
|
<property name="margin-start">6</property>
|
||||||
|
<style>
|
||||||
|
<class name="title"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkButton" id="add_project_button">
|
||||||
|
<property name="icon-name">list-add-symbolic</property>
|
||||||
|
<property name="tooltip-text">Add Project</property>
|
||||||
|
<signal name="clicked" handler="on_add_project_clicked"/>
|
||||||
|
<style>
|
||||||
|
<class name="flat"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child type="end">
|
||||||
|
<object class="GtkMenuButton" id="import_menu_button">
|
||||||
|
<property name="icon-name">papyrus-vertical-symbolic</property>
|
||||||
|
<property name="tooltip-text">Import</property>
|
||||||
|
<property name="menu-model">import_menu</property>
|
||||||
|
<style>
|
||||||
|
<class name="flat"/>
|
||||||
|
</style>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
|
||||||
|
<!-- Sidebar Content -->
|
||||||
|
<property name="content">
|
||||||
|
<object class="GtkBox">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
|
||||||
|
<!-- Projects List -->
|
||||||
|
<child>
|
||||||
|
<object class="GtkScrolledWindow">
|
||||||
|
<property name="vexpand">True</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkListBox" id="projects_listbox">
|
||||||
<style>
|
<style>
|
||||||
<class name="title"/>
|
<class name="navigation-sidebar"/>
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
<child type="start">
|
|
||||||
<object class="GtkWindowControls">
|
|
||||||
<property name="side">start</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child type="end">
|
|
||||||
<object class="GtkMenuButton" id="sidebar_hamburger_button">
|
|
||||||
<property name="primary">True</property>
|
|
||||||
<property name="icon-name">open-menu-symbolic</property>
|
|
||||||
<property name="tooltip-text">Menu</property>
|
|
||||||
<property name="menu-model">sidebar_menu</property>
|
|
||||||
<style>
|
|
||||||
<class name="flat"/>
|
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
<!-- Sidebar Content -->
|
|
||||||
<property name="content">
|
|
||||||
<object class="GtkBox">
|
|
||||||
<property name="orientation">vertical</property>
|
|
||||||
|
|
||||||
<!-- Projects List -->
|
|
||||||
<child>
|
|
||||||
<object class="GtkScrolledWindow">
|
|
||||||
<property name="vexpand">True</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkListBox" id="projects_listbox">
|
|
||||||
<style>
|
|
||||||
<class name="navigation-sidebar"/>
|
|
||||||
</style>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
</object>
|
||||||
</property>
|
</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
|
||||||
<!-- RIGHT: Main Content Panel with AdwToolbarView -->
|
<!-- RIGHT: Main Content Panel with AdwToolbarView -->
|
||||||
<property name="content">
|
<property name="end-child">
|
||||||
<object class="AdwToolbarView">
|
<object class="AdwToolbarView">
|
||||||
|
|
||||||
<!-- Main Header Bar -->
|
<!-- Main Header Bar -->
|
||||||
<child type="top">
|
<child type="top">
|
||||||
<object class="AdwHeaderBar">
|
<object class="AdwHeaderBar">
|
||||||
<property name="show-title">False</property>
|
<property name="show-title">False</property>
|
||||||
<property name="show-start-title-buttons">False</property>
|
<property name="show-start-title-buttons">False</property>
|
||||||
<property name="show-end-title-buttons">False</property>
|
<property name="show-end-title-buttons">False</property>
|
||||||
|
|
||||||
<!-- Sidebar toggle (only visible when collapsed) -->
|
<!-- Left side buttons -->
|
||||||
<child type="start">
|
<child type="start">
|
||||||
<object class="GtkToggleButton" id="sidebar_toggle_button">
|
<object class="GtkButton" id="save_request_button">
|
||||||
<property name="icon-name">sidebar-show-symbolic</property>
|
<property name="icon-name">document-save-symbolic</property>
|
||||||
<property name="tooltip-text">Show Sidebar</property>
|
<property name="tooltip-text">Save Current Request (Ctrl+S)</property>
|
||||||
<style>
|
<signal name="clicked" handler="on_save_request_clicked"/>
|
||||||
<class name="flat"/>
|
<style>
|
||||||
</style>
|
<class name="flat"/>
|
||||||
<binding name="visible">
|
</style>
|
||||||
<lookup name="collapsed">split_view</lookup>
|
</object>
|
||||||
</binding>
|
</child>
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<!-- Left side buttons -->
|
<child type="start">
|
||||||
<child type="start">
|
<object class="GtkButton" id="export_request_button">
|
||||||
<object class="GtkButton" id="save_request_button">
|
<property name="icon-name">export-symbolic</property>
|
||||||
<property name="icon-name">document-save-symbolic</property>
|
<property name="tooltip-text">Export as cURL</property>
|
||||||
<property name="tooltip-text">Save Current Request (Ctrl+S)</property>
|
<signal name="clicked" handler="on_export_request_clicked"/>
|
||||||
<signal name="clicked" handler="on_save_request_clicked"/>
|
<style>
|
||||||
<style>
|
<class name="flat"/>
|
||||||
<class name="flat"/>
|
</style>
|
||||||
</style>
|
</object>
|
||||||
</object>
|
</child>
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- Window controls: separate end child so it's always rightmost -->
|
<!-- Right side buttons -->
|
||||||
<child type="end">
|
<child type="end">
|
||||||
<object class="GtkWindowControls">
|
<object class="GtkBox">
|
||||||
<property name="side">end</property>
|
<property name="spacing">6</property>
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
|
|
||||||
<!-- Right side buttons -->
|
<!-- New Request Button -->
|
||||||
<child type="end">
|
<child>
|
||||||
<object class="GtkButton" id="new_request_button">
|
<object class="GtkButton" id="new_request_button">
|
||||||
<property name="icon-name">list-add-symbolic</property>
|
<property name="icon-name">list-add-symbolic</property>
|
||||||
<property name="tooltip-text">New Request (Ctrl+T)</property>
|
<property name="tooltip-text">New Request (Ctrl+T)</property>
|
||||||
@ -174,8 +146,27 @@
|
|||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
|
<!-- Main Menu -->
|
||||||
|
<child>
|
||||||
|
<object class="GtkMenuButton">
|
||||||
|
<property name="primary">True</property>
|
||||||
|
<property name="icon-name">open-menu-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">Main Menu</property>
|
||||||
|
<property name="menu-model">primary_menu</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
|
||||||
|
<!-- Window Controls -->
|
||||||
|
<child>
|
||||||
|
<object class="GtkWindowControls">
|
||||||
|
<property name="side">end</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
|
||||||
<!-- Tab Bar as separate top bar -->
|
<!-- Tab Bar as separate top bar -->
|
||||||
<child type="top">
|
<child type="top">
|
||||||
@ -258,14 +249,22 @@
|
|||||||
</property>
|
</property>
|
||||||
</object>
|
</object>
|
||||||
</property>
|
</property>
|
||||||
|
|
||||||
<!-- Collapse sidebar when window is narrow -->
|
|
||||||
<child>
|
|
||||||
<object class="AdwBreakpoint">
|
|
||||||
<condition>max-width: 700sp</condition>
|
|
||||||
<setter object="split_view" property="collapsed">True</setter>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<menu id="primary_menu">
|
||||||
|
<section>
|
||||||
|
<item>
|
||||||
|
<attribute name="label" translatable="yes">_Preferences</attribute>
|
||||||
|
<attribute name="action">app.preferences</attribute>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<attribute name="label" translatable="yes">_Keyboard Shortcuts</attribute>
|
||||||
|
<attribute name="action">app.shortcuts</attribute>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<attribute name="label" translatable="yes">_About Roster</attribute>
|
||||||
|
<attribute name="action">app.about</attribute>
|
||||||
|
</item>
|
||||||
|
</section>
|
||||||
|
</menu>
|
||||||
</interface>
|
</interface>
|
||||||
|
|||||||
@ -48,8 +48,6 @@ roster_sources = [
|
|||||||
'wsdl_import_dialog.py',
|
'wsdl_import_dialog.py',
|
||||||
'openapi_importer.py',
|
'openapi_importer.py',
|
||||||
'openapi_import_dialog.py',
|
'openapi_import_dialog.py',
|
||||||
'http_file_importer.py',
|
|
||||||
'http_file_import_dialog.py',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
install_data(roster_sources, install_dir: moduledir)
|
install_data(roster_sources, install_dir: moduledir)
|
||||||
|
|||||||
@ -80,24 +80,28 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
"""Build the complete UI for this tab."""
|
"""Build the complete UI for this tab."""
|
||||||
|
|
||||||
# URL Input Section
|
# URL Input Section - outer container (store as instance var for dynamic updates)
|
||||||
self.url_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
self.url_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||||
self.url_container.set_margin_start(12)
|
self.url_container.set_margin_start(12)
|
||||||
self.url_container.set_margin_end(12)
|
self.url_container.set_margin_end(12)
|
||||||
self.url_container.set_margin_top(12)
|
self.url_container.set_margin_top(12)
|
||||||
self.url_container.set_margin_bottom(12)
|
self.url_container.set_margin_bottom(12)
|
||||||
|
|
||||||
|
# Environment Selector (left-aligned, outside clamp)
|
||||||
if self.project_id:
|
if self.project_id:
|
||||||
self._build_environment_selector_inline(self.url_container)
|
self._build_environment_selector_inline(self.url_container)
|
||||||
|
# Add visual separator/spacer after environment
|
||||||
self.env_separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
|
self.env_separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
|
||||||
self.env_separator.set_margin_start(12)
|
self.env_separator.set_margin_start(12)
|
||||||
self.env_separator.set_margin_end(12)
|
self.env_separator.set_margin_end(12)
|
||||||
self.url_container.append(self.env_separator)
|
self.url_container.append(self.env_separator)
|
||||||
|
|
||||||
|
# URL bar (method, URL, send) - centered in clamp
|
||||||
url_clamp = Adw.Clamp(maximum_size=1000)
|
url_clamp = Adw.Clamp(maximum_size=1000)
|
||||||
url_clamp.set_hexpand(True)
|
url_clamp.set_hexpand(True)
|
||||||
self.url_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
self.url_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||||
|
|
||||||
|
# Method Dropdown
|
||||||
self.method_dropdown = Gtk.DropDown()
|
self.method_dropdown = Gtk.DropDown()
|
||||||
methods = Gtk.StringList()
|
methods = Gtk.StringList()
|
||||||
for method in ["GET", "POST", "PUT", "DELETE"]:
|
for method in ["GET", "POST", "PUT", "DELETE"]:
|
||||||
@ -107,12 +111,13 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
self.method_dropdown.set_enable_search(False)
|
self.method_dropdown.set_enable_search(False)
|
||||||
self.url_box.append(self.method_dropdown)
|
self.url_box.append(self.method_dropdown)
|
||||||
|
|
||||||
|
# URL Entry
|
||||||
self.url_entry = Gtk.Entry()
|
self.url_entry = Gtk.Entry()
|
||||||
self.url_entry.set_placeholder_text("Enter URL...")
|
self.url_entry.set_placeholder_text("Enter URL...")
|
||||||
self.url_entry.set_hexpand(True)
|
self.url_entry.set_hexpand(True)
|
||||||
self.url_entry.add_css_class("url-entry")
|
|
||||||
self.url_box.append(self.url_entry)
|
self.url_box.append(self.url_entry)
|
||||||
|
|
||||||
|
# Send Button
|
||||||
self.send_button = Gtk.Button(icon_name="media-playback-start-symbolic")
|
self.send_button = Gtk.Button(icon_name="media-playback-start-symbolic")
|
||||||
self.send_button.set_tooltip_text("Send Request")
|
self.send_button.set_tooltip_text("Send Request")
|
||||||
self.send_button.add_css_class("suggested-action")
|
self.send_button.add_css_class("suggested-action")
|
||||||
@ -122,89 +127,63 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
self.url_container.append(url_clamp)
|
self.url_container.append(url_clamp)
|
||||||
self.append(self.url_container)
|
self.append(self.url_container)
|
||||||
|
|
||||||
# Build content panels
|
# Horizontal Split: Request | Response
|
||||||
self.request_panel = self._create_request_panel()
|
split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
|
||||||
self.response_panel = self._create_response_panel()
|
split_pane.set_vexpand(True)
|
||||||
|
split_pane.set_position(UI_PANE_REQUEST_RESPONSE_POSITION)
|
||||||
# Normal layout: horizontal split pane
|
split_pane.set_shrink_start_child(False)
|
||||||
self.split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
|
split_pane.set_shrink_end_child(False)
|
||||||
self.split_pane.set_vexpand(True)
|
split_pane.set_resize_start_child(True)
|
||||||
self.split_pane.set_position(UI_PANE_REQUEST_RESPONSE_POSITION)
|
split_pane.set_resize_end_child(True)
|
||||||
self.split_pane.set_shrink_start_child(False)
|
|
||||||
self.split_pane.set_shrink_end_child(False)
|
|
||||||
self.split_pane.set_resize_start_child(True)
|
|
||||||
self.split_pane.set_resize_end_child(False)
|
|
||||||
self.split_pane.set_start_child(self.request_panel)
|
|
||||||
self.split_pane.set_end_child(self.response_panel)
|
|
||||||
|
|
||||||
# Narrow layout: single panel with Request/Response toggle
|
|
||||||
self._build_narrow_layout()
|
|
||||||
self._narrow_mode = False
|
|
||||||
|
|
||||||
self.append(self.split_pane)
|
|
||||||
|
|
||||||
def _create_request_panel(self) -> Gtk.Box:
|
|
||||||
"""Create the request panel with a responsive tab switcher."""
|
|
||||||
request_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
||||||
request_panel.set_size_request(220, -1)
|
|
||||||
|
|
||||||
# Responsive tab switcher (custom, so orientation can change)
|
|
||||||
self.request_tab_switcher = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
|
||||||
self.request_tab_switcher.set_halign(Gtk.Align.CENTER)
|
|
||||||
self.request_tab_switcher.set_margin_top(8)
|
|
||||||
self.request_tab_switcher.set_margin_bottom(8)
|
|
||||||
self.request_tab_switcher.add_css_class("request-tab-switcher")
|
|
||||||
|
|
||||||
|
# Request Panel
|
||||||
|
request_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
self.request_stack = Gtk.Stack()
|
self.request_stack = Gtk.Stack()
|
||||||
self.request_stack.set_vexpand(True)
|
self.request_stack.set_vexpand(True)
|
||||||
self.request_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
self.request_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
||||||
self.request_stack.set_transition_duration(150)
|
self.request_stack.set_transition_duration(150)
|
||||||
|
|
||||||
# Build tab pages
|
request_switcher = Gtk.StackSwitcher()
|
||||||
|
request_switcher.set_stack(self.request_stack)
|
||||||
|
request_switcher.set_halign(Gtk.Align.CENTER)
|
||||||
|
request_switcher.set_margin_top(8)
|
||||||
|
request_switcher.set_margin_bottom(8)
|
||||||
|
|
||||||
|
request_box.append(request_switcher)
|
||||||
|
request_box.append(self.request_stack)
|
||||||
|
|
||||||
|
# Headers tab
|
||||||
self._build_headers_tab()
|
self._build_headers_tab()
|
||||||
|
|
||||||
|
# Body tab
|
||||||
self._build_body_tab()
|
self._build_body_tab()
|
||||||
|
|
||||||
|
# Scripts tab
|
||||||
self._build_scripts_tab()
|
self._build_scripts_tab()
|
||||||
|
|
||||||
# Build linked toggle buttons for each page
|
split_pane.set_start_child(request_box)
|
||||||
self._request_tab_buttons = {}
|
|
||||||
first_btn = None
|
|
||||||
for page_name, label in [("headers", "Headers"), ("body", "Body"), ("scripts", "Scripts")]:
|
|
||||||
btn = Gtk.ToggleButton(label=label)
|
|
||||||
btn.add_css_class("flat")
|
|
||||||
if first_btn is None:
|
|
||||||
btn.set_active(True)
|
|
||||||
first_btn = btn
|
|
||||||
else:
|
|
||||||
btn.set_group(first_btn)
|
|
||||||
btn.connect("toggled", self._on_request_tab_toggled, page_name)
|
|
||||||
self.request_tab_switcher.append(btn)
|
|
||||||
self._request_tab_buttons[page_name] = btn
|
|
||||||
|
|
||||||
request_panel.append(self.request_tab_switcher)
|
# Response Panel
|
||||||
request_panel.append(self.request_stack)
|
|
||||||
|
|
||||||
# Switch switcher orientation based on available width
|
|
||||||
request_panel.connect("notify::width", self._on_request_panel_width_changed)
|
|
||||||
|
|
||||||
return request_panel
|
|
||||||
|
|
||||||
def _create_response_panel(self) -> Gtk.Box:
|
|
||||||
"""Create the response panel."""
|
|
||||||
response_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
response_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
response_box.set_size_request(250, -1)
|
|
||||||
|
|
||||||
|
# Stack switcher at the top
|
||||||
response_switcher = Gtk.StackSwitcher()
|
response_switcher = Gtk.StackSwitcher()
|
||||||
response_switcher.set_halign(Gtk.Align.START)
|
response_switcher.set_halign(Gtk.Align.CENTER)
|
||||||
response_switcher.set_valign(Gtk.Align.CENTER)
|
response_switcher.set_margin_top(8)
|
||||||
|
response_switcher.set_margin_bottom(8)
|
||||||
|
response_box.append(response_switcher)
|
||||||
|
|
||||||
|
# Create a vertical paned for response stack and result panels
|
||||||
self.response_main_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
|
self.response_main_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
|
||||||
self.response_main_paned.set_vexpand(True)
|
self.response_main_paned.set_vexpand(True)
|
||||||
self.response_main_paned.set_position(UI_PANE_RESPONSE_DETAILS_POSITION)
|
self.response_main_paned.set_position(UI_PANE_RESPONSE_DETAILS_POSITION)
|
||||||
self.response_main_paned.set_shrink_start_child(False)
|
self.response_main_paned.set_shrink_start_child(False)
|
||||||
|
# Don't allow results panels to shrink below their minimum size
|
||||||
self.response_main_paned.set_shrink_end_child(False)
|
self.response_main_paned.set_shrink_end_child(False)
|
||||||
self.response_main_paned.set_resize_start_child(True)
|
self.response_main_paned.set_resize_start_child(True)
|
||||||
self.response_main_paned.set_resize_end_child(True)
|
self.response_main_paned.set_resize_end_child(True)
|
||||||
|
|
||||||
|
# Response Stack
|
||||||
self.response_stack = Gtk.Stack()
|
self.response_stack = Gtk.Stack()
|
||||||
self.response_stack.set_vexpand(True)
|
self.response_stack.set_vexpand(True)
|
||||||
self.response_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
self.response_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
||||||
@ -213,6 +192,7 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
response_switcher.set_stack(self.response_stack)
|
response_switcher.set_stack(self.response_stack)
|
||||||
self.response_main_paned.set_start_child(self.response_stack)
|
self.response_main_paned.set_start_child(self.response_stack)
|
||||||
|
|
||||||
|
# Response headers
|
||||||
headers_scroll = Gtk.ScrolledWindow()
|
headers_scroll = Gtk.ScrolledWindow()
|
||||||
headers_scroll.set_vexpand(True)
|
headers_scroll.set_vexpand(True)
|
||||||
self.response_headers_textview = Gtk.TextView()
|
self.response_headers_textview = Gtk.TextView()
|
||||||
@ -225,6 +205,7 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
headers_scroll.set_child(self.response_headers_textview)
|
headers_scroll.set_child(self.response_headers_textview)
|
||||||
self.response_stack.add_titled(headers_scroll, "headers", "Headers")
|
self.response_stack.add_titled(headers_scroll, "headers", "Headers")
|
||||||
|
|
||||||
|
# Response body
|
||||||
body_scroll = Gtk.ScrolledWindow()
|
body_scroll = Gtk.ScrolledWindow()
|
||||||
body_scroll.set_vexpand(True)
|
body_scroll.set_vexpand(True)
|
||||||
self.response_body_sourceview = GtkSource.View()
|
self.response_body_sourceview = GtkSource.View()
|
||||||
@ -236,8 +217,10 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
self.response_body_sourceview.set_top_margin(12)
|
self.response_body_sourceview.set_top_margin(12)
|
||||||
self.response_body_sourceview.set_bottom_margin(12)
|
self.response_body_sourceview.set_bottom_margin(12)
|
||||||
|
|
||||||
|
# Set up theme
|
||||||
self._setup_sourceview_theme()
|
self._setup_sourceview_theme()
|
||||||
|
|
||||||
|
# Set font
|
||||||
css_provider = Gtk.CssProvider()
|
css_provider = Gtk.CssProvider()
|
||||||
css_provider.load_from_data(b"""
|
css_provider.load_from_data(b"""
|
||||||
textview {
|
textview {
|
||||||
@ -254,19 +237,24 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
body_scroll.set_child(self.response_body_sourceview)
|
body_scroll.set_child(self.response_body_sourceview)
|
||||||
self.response_stack.add_titled(body_scroll, "body", "Body")
|
self.response_stack.add_titled(body_scroll, "body", "Body")
|
||||||
|
|
||||||
|
# Create a second paned for preprocessing and script results
|
||||||
self.results_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
|
self.results_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
|
||||||
self.results_paned.set_vexpand(True)
|
self.results_paned.set_vexpand(True)
|
||||||
self.results_paned.set_position(UI_PANE_RESULTS_PANEL_POSITION)
|
self.results_paned.set_position(UI_PANE_RESULTS_PANEL_POSITION)
|
||||||
self.results_paned.set_visible(False)
|
self.results_paned.set_visible(False) # Initially hidden until scripts produce output
|
||||||
|
# Don't allow children to shrink below their minimum size
|
||||||
self.results_paned.set_shrink_start_child(False)
|
self.results_paned.set_shrink_start_child(False)
|
||||||
self.results_paned.set_shrink_end_child(False)
|
self.results_paned.set_shrink_end_child(False)
|
||||||
self.results_paned.set_resize_start_child(True)
|
self.results_paned.set_resize_start_child(True)
|
||||||
self.results_paned.set_resize_end_child(True)
|
self.results_paned.set_resize_end_child(True)
|
||||||
|
|
||||||
|
# Preprocessing Results Panel (resizable)
|
||||||
self.preprocessing_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
self.preprocessing_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||||
self.preprocessing_results_container.set_visible(False)
|
self.preprocessing_results_container.set_visible(False) # Initially hidden
|
||||||
|
# Set minimum height to ensure header is always visible (header ~40px + content min 60px)
|
||||||
self.preprocessing_results_container.set_size_request(-1, 100)
|
self.preprocessing_results_container.set_size_request(-1, 100)
|
||||||
|
|
||||||
|
# Header
|
||||||
preprocessing_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
preprocessing_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||||
preprocessing_header.set_margin_start(12)
|
preprocessing_header.set_margin_start(12)
|
||||||
preprocessing_header.set_margin_end(12)
|
preprocessing_header.set_margin_end(12)
|
||||||
@ -278,16 +266,19 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
preprocessing_label.set_halign(Gtk.Align.START)
|
preprocessing_label.set_halign(Gtk.Align.START)
|
||||||
preprocessing_header.append(preprocessing_label)
|
preprocessing_header.append(preprocessing_label)
|
||||||
|
|
||||||
|
# Spacer
|
||||||
preprocessing_spacer = Gtk.Box()
|
preprocessing_spacer = Gtk.Box()
|
||||||
preprocessing_spacer.set_hexpand(True)
|
preprocessing_spacer.set_hexpand(True)
|
||||||
preprocessing_header.append(preprocessing_spacer)
|
preprocessing_header.append(preprocessing_spacer)
|
||||||
|
|
||||||
|
# Status icon
|
||||||
self.preprocessing_status_icon = Gtk.Image()
|
self.preprocessing_status_icon = Gtk.Image()
|
||||||
self.preprocessing_status_icon.set_from_icon_name("object-select-symbolic")
|
self.preprocessing_status_icon.set_from_icon_name("object-select-symbolic")
|
||||||
preprocessing_header.append(self.preprocessing_status_icon)
|
preprocessing_header.append(self.preprocessing_status_icon)
|
||||||
|
|
||||||
self.preprocessing_results_container.append(preprocessing_header)
|
self.preprocessing_results_container.append(preprocessing_header)
|
||||||
|
|
||||||
|
# Output text view (scrollable, resizable)
|
||||||
self.preprocessing_output_scroll = Gtk.ScrolledWindow()
|
self.preprocessing_output_scroll = Gtk.ScrolledWindow()
|
||||||
self.preprocessing_output_scroll.set_vexpand(True)
|
self.preprocessing_output_scroll.set_vexpand(True)
|
||||||
self.preprocessing_output_scroll.set_min_content_height(60)
|
self.preprocessing_output_scroll.set_min_content_height(60)
|
||||||
@ -309,10 +300,13 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
|
|
||||||
self.results_paned.set_start_child(self.preprocessing_results_container)
|
self.results_paned.set_start_child(self.preprocessing_results_container)
|
||||||
|
|
||||||
|
# Script Results Panel (resizable)
|
||||||
self.script_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
self.script_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||||
self.script_results_container.set_visible(False)
|
self.script_results_container.set_visible(False) # Initially hidden
|
||||||
|
# Set minimum height to ensure header is always visible (header ~40px + content min 60px)
|
||||||
self.script_results_container.set_size_request(-1, 100)
|
self.script_results_container.set_size_request(-1, 100)
|
||||||
|
|
||||||
|
# Header
|
||||||
results_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
results_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||||
results_header.set_margin_start(12)
|
results_header.set_margin_start(12)
|
||||||
results_header.set_margin_end(12)
|
results_header.set_margin_end(12)
|
||||||
@ -324,16 +318,19 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
results_label.set_halign(Gtk.Align.START)
|
results_label.set_halign(Gtk.Align.START)
|
||||||
results_header.append(results_label)
|
results_header.append(results_label)
|
||||||
|
|
||||||
|
# Spacer
|
||||||
results_spacer = Gtk.Box()
|
results_spacer = Gtk.Box()
|
||||||
results_spacer.set_hexpand(True)
|
results_spacer.set_hexpand(True)
|
||||||
results_header.append(results_spacer)
|
results_header.append(results_spacer)
|
||||||
|
|
||||||
|
# Status icon
|
||||||
self.script_status_icon = Gtk.Image()
|
self.script_status_icon = Gtk.Image()
|
||||||
self.script_status_icon.set_from_icon_name("object-select-symbolic")
|
self.script_status_icon.set_from_icon_name("object-select-symbolic")
|
||||||
results_header.append(self.script_status_icon)
|
results_header.append(self.script_status_icon)
|
||||||
|
|
||||||
self.script_results_container.append(results_header)
|
self.script_results_container.append(results_header)
|
||||||
|
|
||||||
|
# Output text view (scrollable, resizable)
|
||||||
self.script_output_scroll = Gtk.ScrolledWindow()
|
self.script_output_scroll = Gtk.ScrolledWindow()
|
||||||
self.script_output_scroll.set_vexpand(True)
|
self.script_output_scroll.set_vexpand(True)
|
||||||
self.script_output_scroll.set_min_content_height(60)
|
self.script_output_scroll.set_min_content_height(60)
|
||||||
@ -354,23 +351,20 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
self.script_results_container.append(self.script_output_scroll)
|
self.script_results_container.append(self.script_output_scroll)
|
||||||
|
|
||||||
self.results_paned.set_end_child(self.script_results_container)
|
self.results_paned.set_end_child(self.script_results_container)
|
||||||
|
|
||||||
|
# Set the results paned as the end child of the main response paned
|
||||||
self.response_main_paned.set_end_child(self.results_paned)
|
self.response_main_paned.set_end_child(self.results_paned)
|
||||||
|
|
||||||
|
# Add the main paned to response_box
|
||||||
response_box.append(self.response_main_paned)
|
response_box.append(self.response_main_paned)
|
||||||
|
|
||||||
bottom_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
# Status Bar
|
||||||
bottom_bar.set_margin_start(6)
|
|
||||||
bottom_bar.set_margin_end(12)
|
|
||||||
bottom_bar.set_margin_top(4)
|
|
||||||
bottom_bar.set_margin_bottom(4)
|
|
||||||
|
|
||||||
bottom_bar.append(response_switcher)
|
|
||||||
|
|
||||||
spacer = Gtk.Box()
|
|
||||||
spacer.set_hexpand(True)
|
|
||||||
bottom_bar.append(spacer)
|
|
||||||
|
|
||||||
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||||
|
status_box.set_margin_start(12)
|
||||||
|
status_box.set_margin_end(12)
|
||||||
|
status_box.set_margin_top(6)
|
||||||
|
status_box.set_margin_bottom(6)
|
||||||
|
|
||||||
self.status_label = Gtk.Label(label="Ready")
|
self.status_label = Gtk.Label(label="Ready")
|
||||||
self.status_label.add_css_class("heading")
|
self.status_label.add_css_class("heading")
|
||||||
status_box.append(self.status_label)
|
status_box.append(self.status_label)
|
||||||
@ -381,95 +375,16 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
self.size_label = Gtk.Label(label="")
|
self.size_label = Gtk.Label(label="")
|
||||||
status_box.append(self.size_label)
|
status_box.append(self.size_label)
|
||||||
|
|
||||||
bottom_bar.append(status_box)
|
# Spacer
|
||||||
response_box.append(bottom_bar)
|
spacer = Gtk.Box()
|
||||||
|
spacer.set_hexpand(True)
|
||||||
|
status_box.append(spacer)
|
||||||
|
|
||||||
return response_box
|
response_box.append(status_box)
|
||||||
|
|
||||||
def _build_narrow_layout(self) -> None:
|
split_pane.set_end_child(response_box)
|
||||||
"""Build the narrow single-panel layout with Request/Response toggle."""
|
|
||||||
self.narrow_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
||||||
self.narrow_box.set_vexpand(True)
|
|
||||||
|
|
||||||
toggle_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
self.append(split_pane)
|
||||||
toggle_bar.set_halign(Gtk.Align.CENTER)
|
|
||||||
toggle_bar.set_margin_top(6)
|
|
||||||
toggle_bar.set_margin_bottom(6)
|
|
||||||
toggle_bar.add_css_class("linked")
|
|
||||||
|
|
||||||
self.narrow_request_btn = Gtk.ToggleButton(label="Request")
|
|
||||||
self.narrow_request_btn.set_active(True)
|
|
||||||
|
|
||||||
self.narrow_response_btn = Gtk.ToggleButton(label="Response")
|
|
||||||
self.narrow_response_btn.set_group(self.narrow_request_btn)
|
|
||||||
|
|
||||||
self.narrow_request_btn.connect("toggled", self._on_narrow_panel_toggled, "request")
|
|
||||||
self.narrow_response_btn.connect("toggled", self._on_narrow_panel_toggled, "response")
|
|
||||||
|
|
||||||
toggle_bar.append(self.narrow_request_btn)
|
|
||||||
toggle_bar.append(self.narrow_response_btn)
|
|
||||||
self.narrow_box.append(toggle_bar)
|
|
||||||
|
|
||||||
self.narrow_stack = Gtk.Stack()
|
|
||||||
self.narrow_stack.set_vexpand(True)
|
|
||||||
self.narrow_stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
|
|
||||||
self.narrow_stack.set_transition_duration(200)
|
|
||||||
self.narrow_box.append(self.narrow_stack)
|
|
||||||
|
|
||||||
def set_narrow_mode(self, narrow: bool) -> None:
|
|
||||||
"""Switch between split-pane (normal) and single-panel (narrow) layout."""
|
|
||||||
if narrow == self._narrow_mode:
|
|
||||||
return
|
|
||||||
self._narrow_mode = narrow
|
|
||||||
|
|
||||||
if narrow:
|
|
||||||
self.split_pane.set_start_child(None)
|
|
||||||
self.split_pane.set_end_child(None)
|
|
||||||
self.remove(self.split_pane)
|
|
||||||
|
|
||||||
self.narrow_stack.add_named(self.request_panel, "request")
|
|
||||||
self.narrow_stack.add_named(self.response_panel, "response")
|
|
||||||
|
|
||||||
# Sync stack visible child to match current button state
|
|
||||||
# (button state persists across wide↔narrow transitions)
|
|
||||||
if self.narrow_response_btn.get_active():
|
|
||||||
self.narrow_stack.set_visible_child_name("response")
|
|
||||||
else:
|
|
||||||
self.narrow_stack.set_visible_child_name("request")
|
|
||||||
|
|
||||||
self.append(self.narrow_box)
|
|
||||||
else:
|
|
||||||
self.narrow_stack.remove(self.request_panel)
|
|
||||||
self.narrow_stack.remove(self.response_panel)
|
|
||||||
self.remove(self.narrow_box)
|
|
||||||
|
|
||||||
self.split_pane.set_start_child(self.request_panel)
|
|
||||||
self.split_pane.set_end_child(self.response_panel)
|
|
||||||
self.append(self.split_pane)
|
|
||||||
|
|
||||||
def _on_request_tab_toggled(self, button: Gtk.ToggleButton, page_name: str) -> None:
|
|
||||||
"""Handle request tab button toggle."""
|
|
||||||
if button.get_active():
|
|
||||||
self.request_stack.set_visible_child_name(page_name)
|
|
||||||
|
|
||||||
def _on_request_panel_width_changed(self, widget, pspec) -> None:
|
|
||||||
"""Switch request tab switcher to vertical when panel is narrow."""
|
|
||||||
width = widget.get_width()
|
|
||||||
if width > 0 and width < 260:
|
|
||||||
if self.request_tab_switcher.get_orientation() != Gtk.Orientation.VERTICAL:
|
|
||||||
self.request_tab_switcher.set_orientation(Gtk.Orientation.VERTICAL)
|
|
||||||
self.request_tab_switcher.set_halign(Gtk.Align.START)
|
|
||||||
self.request_tab_switcher.set_margin_start(8)
|
|
||||||
elif width >= 260:
|
|
||||||
if self.request_tab_switcher.get_orientation() != Gtk.Orientation.HORIZONTAL:
|
|
||||||
self.request_tab_switcher.set_orientation(Gtk.Orientation.HORIZONTAL)
|
|
||||||
self.request_tab_switcher.set_halign(Gtk.Align.CENTER)
|
|
||||||
self.request_tab_switcher.set_margin_start(0)
|
|
||||||
|
|
||||||
def _on_narrow_panel_toggled(self, button: Gtk.ToggleButton, panel_name: str) -> None:
|
|
||||||
"""Handle narrow mode Request/Response toggle."""
|
|
||||||
if button.get_active():
|
|
||||||
self.narrow_stack.set_visible_child_name(panel_name)
|
|
||||||
|
|
||||||
def _build_headers_tab(self) -> None:
|
def _build_headers_tab(self) -> None:
|
||||||
"""Build the headers tab."""
|
"""Build the headers tab."""
|
||||||
@ -1012,11 +927,6 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
# Switch to body tab
|
# Switch to body tab
|
||||||
self.response_stack.set_visible_child_name("body")
|
self.response_stack.set_visible_child_name("body")
|
||||||
|
|
||||||
# In narrow mode, automatically show the response panel
|
|
||||||
if self._narrow_mode:
|
|
||||||
self.narrow_stack.set_visible_child_name("response")
|
|
||||||
self.narrow_response_btn.set_active(True)
|
|
||||||
|
|
||||||
def display_error(self, error: str) -> None:
|
def display_error(self, error: str) -> None:
|
||||||
"""Display error in this tab's UI."""
|
"""Display error in this tab's UI."""
|
||||||
self.status_label.set_text("Error")
|
self.status_label.set_text("Error")
|
||||||
@ -1032,10 +942,6 @@ class RequestTabWidget(Gtk.Box):
|
|||||||
source_buffer.set_text(error)
|
source_buffer.set_text(error)
|
||||||
source_buffer.set_language(None)
|
source_buffer.set_language(None)
|
||||||
|
|
||||||
if self._narrow_mode:
|
|
||||||
self.narrow_stack.set_visible_child_name("response")
|
|
||||||
self.narrow_response_btn.set_active(True)
|
|
||||||
|
|
||||||
def display_script_results(self, script_result):
|
def display_script_results(self, script_result):
|
||||||
"""Display script execution results."""
|
"""Display script execution results."""
|
||||||
from .script_executor import ScriptResult
|
from .script_executor import ScriptResult
|
||||||
|
|||||||
@ -72,8 +72,7 @@
|
|||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkListBox" id="requests_listbox">
|
<object class="GtkListBox" id="requests_listbox">
|
||||||
<property name="margin-start">6</property>
|
<property name="margin-start">12</property>
|
||||||
<property name="margin-end">6</property>
|
|
||||||
<style>
|
<style>
|
||||||
<class name="navigation-sidebar"/>
|
<class name="navigation-sidebar"/>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -11,9 +11,10 @@
|
|||||||
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkLabel" id="method_label">
|
<object class="GtkLabel" id="method_label">
|
||||||
<property name="xalign">0.5</property>
|
<property name="width-chars">6</property>
|
||||||
<style>
|
<style>
|
||||||
<class name="method-chip"/>
|
<class name="caption"/>
|
||||||
|
<class name="dim-label"/>
|
||||||
</style>
|
</style>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|||||||
@ -20,14 +20,6 @@
|
|||||||
|
|
||||||
from gi.repository import Gtk, GObject
|
from gi.repository import Gtk, GObject
|
||||||
|
|
||||||
_METHOD_CSS = {
|
|
||||||
'GET': 'accent',
|
|
||||||
'POST': 'success',
|
|
||||||
'PUT': 'warning',
|
|
||||||
'PATCH': 'warning',
|
|
||||||
'DELETE': 'error',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/request-item.ui')
|
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/request-item.ui')
|
||||||
class RequestItem(Gtk.Box):
|
class RequestItem(Gtk.Box):
|
||||||
@ -46,9 +38,7 @@ class RequestItem(Gtk.Box):
|
|||||||
def __init__(self, saved_request):
|
def __init__(self, saved_request):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.saved_request = saved_request
|
self.saved_request = saved_request
|
||||||
method = saved_request.request.method
|
self.method_label.set_text(saved_request.request.method)
|
||||||
self.method_label.set_text(method)
|
|
||||||
self.method_label.add_css_class(_METHOD_CSS.get(method, 'dim-label'))
|
|
||||||
self.name_label.set_text(saved_request.name)
|
self.name_label.set_text(saved_request.name)
|
||||||
|
|
||||||
# Add click gesture for loading
|
# Add click gesture for loading
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version('GtkSource', '5')
|
gi.require_version('GtkSource', '5')
|
||||||
from gi.repository import Adw, Gtk, GLib, Gio, GtkSource, GObject
|
from gi.repository import Adw, Gtk, GLib, Gio, GtkSource
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
import logging
|
import logging
|
||||||
from .models import HttpRequest, HttpResponse, HistoryEntry, RequestTab
|
from .models import HttpRequest, HttpResponse, HistoryEntry, RequestTab
|
||||||
@ -38,7 +38,6 @@ from .icon_picker_dialog import IconPickerDialog
|
|||||||
from .environments_dialog import EnvironmentsDialog
|
from .environments_dialog import EnvironmentsDialog
|
||||||
from .wsdl_import_dialog import WsdlImportDialog
|
from .wsdl_import_dialog import WsdlImportDialog
|
||||||
from .openapi_import_dialog import OpenApiImportDialog
|
from .openapi_import_dialog import OpenApiImportDialog
|
||||||
from .http_file_import_dialog import HttpFileImportDialog
|
|
||||||
from .request_tab_widget import RequestTabWidget
|
from .request_tab_widget import RequestTabWidget
|
||||||
from .widgets.history_item import HistoryItem
|
from .widgets.history_item import HistoryItem
|
||||||
from .widgets.project_item import ProjectItem
|
from .widgets.project_item import ProjectItem
|
||||||
@ -63,12 +62,14 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
tab_view = Gtk.Template.Child()
|
tab_view = Gtk.Template.Child()
|
||||||
tab_bar = Gtk.Template.Child()
|
tab_bar = Gtk.Template.Child()
|
||||||
|
|
||||||
# Split view
|
# Panes
|
||||||
split_view = Gtk.Template.Child()
|
main_pane = Gtk.Template.Child()
|
||||||
sidebar_toggle_button = Gtk.Template.Child()
|
|
||||||
|
|
||||||
# Sidebar widgets
|
# Sidebar widgets
|
||||||
projects_listbox = Gtk.Template.Child()
|
projects_listbox = Gtk.Template.Child()
|
||||||
|
add_project_button = Gtk.Template.Child()
|
||||||
|
|
||||||
|
import_menu_button = Gtk.Template.Child()
|
||||||
|
|
||||||
# History (hidden but kept for compatibility)
|
# History (hidden but kept for compatibility)
|
||||||
history_listbox = Gtk.Template.Child()
|
history_listbox = Gtk.Template.Child()
|
||||||
@ -100,17 +101,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
# Setup custom CSS
|
# Setup custom CSS
|
||||||
self._setup_custom_css()
|
self._setup_custom_css()
|
||||||
|
|
||||||
# Bind sidebar toggle button to split view (bidirectional)
|
|
||||||
self.split_view.bind_property(
|
|
||||||
'show-sidebar',
|
|
||||||
self.sidebar_toggle_button,
|
|
||||||
'active',
|
|
||||||
GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE
|
|
||||||
)
|
|
||||||
|
|
||||||
# Switch tab widgets to narrow layout when sidebar collapses
|
|
||||||
self.split_view.connect("notify::collapsed", self._on_split_view_collapsed_changed)
|
|
||||||
|
|
||||||
# Setup UI
|
# Setup UI
|
||||||
self._setup_tab_system()
|
self._setup_tab_system()
|
||||||
self._load_projects()
|
self._load_projects()
|
||||||
@ -122,12 +112,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
# Create first tab
|
# Create first tab
|
||||||
self._create_new_tab()
|
self._create_new_tab()
|
||||||
|
|
||||||
def _on_split_view_collapsed_changed(self, split_view, pspec) -> None:
|
|
||||||
"""Switch all tab widgets to narrow or normal layout based on sidebar state."""
|
|
||||||
is_narrow = split_view.get_collapsed()
|
|
||||||
for widget in self.page_to_widget.values():
|
|
||||||
widget.set_narrow_mode(is_narrow)
|
|
||||||
|
|
||||||
def _on_close_request(self, window) -> bool:
|
def _on_close_request(self, window) -> bool:
|
||||||
"""Handle window close request - warn if there are unsaved changes."""
|
"""Handle window close request - warn if there are unsaved changes."""
|
||||||
# Check if any tabs have unsaved changes
|
# Check if any tabs have unsaved changes
|
||||||
@ -176,15 +160,15 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
/* AdwToolbarView handles header bar heights automatically */
|
/* AdwToolbarView handles header bar heights automatically */
|
||||||
/* Just add minimal custom styling for other elements */
|
/* Just add minimal custom styling for other elements */
|
||||||
|
|
||||||
/* Request tab switcher (Headers/Body/Scripts) */
|
/* Stack switchers styling (Headers/Body tabs) */
|
||||||
.request-tab-switcher button {
|
stackswitcher button {
|
||||||
padding: 6px 16px;
|
padding: 6px 16px;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-tab-switcher button:checked {
|
stackswitcher button:checked {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,23 +202,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
color: @accent_color;
|
color: @accent_color;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* URL entry can shrink to near-zero so the Send button stays visible */
|
|
||||||
entry.url-entry {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Method chips in sidebar request list */
|
|
||||||
.method-chip {
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
font-size: 0.72em;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.method-chip.accent { background-color: alpha(@accent_bg_color, 0.18); }
|
|
||||||
.method-chip.success { background-color: alpha(@success_bg_color, 0.18); }
|
|
||||||
.method-chip.warning { background-color: alpha(@warning_bg_color, 0.18); }
|
|
||||||
.method-chip.error { background-color: alpha(@error_bg_color, 0.18); }
|
|
||||||
""")
|
""")
|
||||||
|
|
||||||
Gtk.StyleContext.add_provider_for_display(
|
Gtk.StyleContext.add_provider_for_display(
|
||||||
@ -414,10 +381,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
# incorrectly marked all variables as undefined.
|
# incorrectly marked all variables as undefined.
|
||||||
widget._update_variable_indicators()
|
widget._update_variable_indicators()
|
||||||
|
|
||||||
# Apply narrow mode if sidebar is currently collapsed
|
|
||||||
if self.split_view.get_collapsed():
|
|
||||||
widget.set_narrow_mode(True)
|
|
||||||
|
|
||||||
# Connect to send button
|
# Connect to send button
|
||||||
widget.send_button.connect("clicked", lambda btn: self._on_send_clicked(widget))
|
widget.send_button.connect("clicked", lambda btn: self._on_send_clicked(widget))
|
||||||
|
|
||||||
@ -532,10 +495,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
self.add_action(action)
|
self.add_action(action)
|
||||||
self.get_application().set_accels_for_action("win.send-request", ["<Control>Return"])
|
self.get_application().set_accels_for_action("win.send-request", ["<Control>Return"])
|
||||||
|
|
||||||
action = Gio.SimpleAction.new("add-project", None)
|
|
||||||
action.connect("activate", lambda a, p: self.on_add_project_clicked(None))
|
|
||||||
self.add_action(action)
|
|
||||||
|
|
||||||
action = Gio.SimpleAction.new("import-openapi", None)
|
action = Gio.SimpleAction.new("import-openapi", None)
|
||||||
action.connect("activate", lambda a, p: self.on_import_openapi_clicked(None))
|
action.connect("activate", lambda a, p: self.on_import_openapi_clicked(None))
|
||||||
self.add_action(action)
|
self.add_action(action)
|
||||||
@ -544,10 +503,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
action.connect("activate", lambda a, p: self.on_import_wsdl_clicked(None))
|
action.connect("activate", lambda a, p: self.on_import_wsdl_clicked(None))
|
||||||
self.add_action(action)
|
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):
|
def _on_send_clicked(self, widget):
|
||||||
"""Handle Send button click from a tab widget."""
|
"""Handle Send button click from a tab widget."""
|
||||||
# Clear previous preprocessing results
|
# Clear previous preprocessing results
|
||||||
@ -977,6 +932,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
item.connect('manage-environments-requested', self._on_manage_environments, project)
|
item.connect('manage-environments-requested', self._on_manage_environments, project)
|
||||||
self.projects_listbox.append(item)
|
self.projects_listbox.append(item)
|
||||||
|
|
||||||
|
@Gtk.Template.Callback()
|
||||||
def on_add_project_clicked(self, button):
|
def on_add_project_clicked(self, button):
|
||||||
"""Show dialog to create project."""
|
"""Show dialog to create project."""
|
||||||
dialog = Adw.AlertDialog()
|
dialog = Adw.AlertDialog()
|
||||||
@ -1466,22 +1422,6 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
widget.original_request = None
|
widget.original_request = None
|
||||||
widget.modified = True
|
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):
|
def on_import_wsdl_clicked(self, button):
|
||||||
"""Open the WSDL import dialog."""
|
"""Open the WSDL import dialog."""
|
||||||
projects = self.project_manager.load_projects()
|
projects = self.project_manager.load_projects()
|
||||||
|
|||||||
@ -51,14 +51,6 @@ _XS_HINTS: Dict[str, str] = {
|
|||||||
'duration': 'duration', 'guid': 'guid',
|
'duration': 'duration', 'guid': 'guid',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Well-known namespace → preferred short prefix
|
|
||||||
_KNOWN_NS_PREFIXES: Dict[str, str] = {
|
|
||||||
'http://schemas.datacontract.org': 'dc',
|
|
||||||
'http://schemas.microsoft.com/2003/10/Serialization/': 'ser',
|
|
||||||
'http://www.w3.org/2001/XMLSchema-instance': 'xsi',
|
|
||||||
'http://www.w3.org/2001/XMLSchema': 'xs',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _q(ns: str, tag: str) -> str:
|
def _q(ns: str, tag: str) -> str:
|
||||||
return f'{{{ns}}}{tag}'
|
return f'{{{ns}}}{tag}'
|
||||||
@ -73,19 +65,6 @@ def _hint(xs_local: str, optional: bool) -> str:
|
|||||||
return f'[{base}{"?" if optional else ""}]'
|
return f'[{base}{"?" if optional else ""}]'
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal parameter tree
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Param:
|
|
||||||
"""Tree node for building a typed SOAP body element."""
|
|
||||||
name: str
|
|
||||||
ns: str = '' # element namespace; '' = inherit op namespace
|
|
||||||
hint: Optional[str] = None # leaf text like '[string]'; None = container node
|
|
||||||
children: list = field(default_factory=list) # list[_Param]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public data classes
|
# Public data classes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -225,7 +204,7 @@ def _parse_wsdl11(root: ET.Element) -> WsdlParseResult:
|
|||||||
error='No SOAP operations found in this WSDL document'
|
error='No SOAP operations found in this WSDL document'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build schema maps: name → (element, namespace)
|
# Build schema maps for parameter extraction
|
||||||
elem_map, type_map = _build_schema_maps(root, _WSDL)
|
elem_map, type_map = _build_schema_maps(root, _WSDL)
|
||||||
|
|
||||||
operations = []
|
operations = []
|
||||||
@ -244,8 +223,9 @@ def _parse_wsdl11(root: ET.Element) -> WsdlParseResult:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_params_wsdl11(root, op_name: str, elem_map, type_map) -> list:
|
def _extract_params_wsdl11(root, op_name: str, elem_map, type_map) -> List[Tuple[str, str]]:
|
||||||
"""Return list[_Param] for the input of a WSDL 1.1 operation."""
|
"""Return [(param_name, hint), …] for the input of a WSDL 1.1 operation."""
|
||||||
|
# Walk portType → message → part element
|
||||||
input_elem_name = _find_input_elem_wsdl11(root, op_name)
|
input_elem_name = _find_input_elem_wsdl11(root, op_name)
|
||||||
|
|
||||||
# Naming-convention fallback
|
# Naming-convention fallback
|
||||||
@ -258,8 +238,7 @@ def _extract_params_wsdl11(root, op_name: str, elem_map, type_map) -> list:
|
|||||||
if not input_elem_name or input_elem_name not in elem_map:
|
if not input_elem_name or input_elem_name not in elem_map:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
elem, elem_ns = elem_map[input_elem_name]
|
return _parse_element(elem_map[input_elem_name], elem_map, type_map)
|
||||||
return _parse_element(elem, elem_ns, elem_map, type_map)
|
|
||||||
|
|
||||||
|
|
||||||
def _find_input_elem_wsdl11(root, op_name: str) -> Optional[str]:
|
def _find_input_elem_wsdl11(root, op_name: str) -> Optional[str]:
|
||||||
@ -324,12 +303,14 @@ def _parse_wsdl20(root: ET.Element) -> WsdlParseResult:
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Collect (soap_action) per operation from SOAP bindings
|
# Collect (soap_action) per operation from SOAP bindings
|
||||||
|
# binding_local_name → {op_local → soap_action}
|
||||||
binding_ops: Dict[str, Dict[str, str]] = {}
|
binding_ops: Dict[str, Dict[str, str]] = {}
|
||||||
for binding in root.iter():
|
for binding in root.iter():
|
||||||
if _local(binding.tag) != 'binding':
|
if _local(binding.tag) != 'binding':
|
||||||
continue
|
continue
|
||||||
b_name = binding.get('name', '')
|
b_name = binding.get('name', '')
|
||||||
b_type = binding.get('type', '')
|
b_type = binding.get('type', '')
|
||||||
|
# Check for SOAP binding type or SOAP child elements
|
||||||
is_soap = (_WSDL2_SOAP in b_type or 'soap' in b_type.lower())
|
is_soap = (_WSDL2_SOAP in b_type or 'soap' in b_type.lower())
|
||||||
if not is_soap:
|
if not is_soap:
|
||||||
is_soap = any(
|
is_soap = any(
|
||||||
@ -344,6 +325,7 @@ def _parse_wsdl20(root: ET.Element) -> WsdlParseResult:
|
|||||||
if _local(child.tag) != 'operation':
|
if _local(child.tag) != 'operation':
|
||||||
continue
|
continue
|
||||||
ref = (child.get('ref') or '').split(':')[-1]
|
ref = (child.get('ref') or '').split(':')[-1]
|
||||||
|
# SOAPAction may be a namespaced attribute
|
||||||
action = (
|
action = (
|
||||||
child.get(_q(_WSDL2_SOAP, 'action'))
|
child.get(_q(_WSDL2_SOAP, 'action'))
|
||||||
or child.get('action')
|
or child.get('action')
|
||||||
@ -358,6 +340,7 @@ def _parse_wsdl20(root: ET.Element) -> WsdlParseResult:
|
|||||||
if service_binding_local and service_binding_local in binding_ops:
|
if service_binding_local and service_binding_local in binding_ops:
|
||||||
op_info = binding_ops[service_binding_local]
|
op_info = binding_ops[service_binding_local]
|
||||||
else:
|
else:
|
||||||
|
# Merge all SOAP binding operations
|
||||||
for ops in binding_ops.values():
|
for ops in binding_ops.values():
|
||||||
for op_name, action in ops.items():
|
for op_name, action in ops.items():
|
||||||
if op_name not in op_info:
|
if op_name not in op_info:
|
||||||
@ -380,6 +363,7 @@ def _parse_wsdl20(root: ET.Element) -> WsdlParseResult:
|
|||||||
error='No SOAP operations found in WSDL 2.0 document'
|
error='No SOAP operations found in WSDL 2.0 document'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build schema maps
|
||||||
elem_map, type_map = _build_schema_maps(root, _WSDL2)
|
elem_map, type_map = _build_schema_maps(root, _WSDL2)
|
||||||
|
|
||||||
operations = []
|
operations = []
|
||||||
@ -398,8 +382,8 @@ def _parse_wsdl20(root: ET.Element) -> WsdlParseResult:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _extract_params_wsdl20(root, op_name: str, elem_map, type_map) -> list:
|
def _extract_params_wsdl20(root, op_name: str, elem_map, type_map) -> List[Tuple[str, str]]:
|
||||||
"""Return list[_Param] for the input of a WSDL 2.0 operation."""
|
"""Return [(param_name, hint), …] for the input of a WSDL 2.0 operation."""
|
||||||
for iface in root.iter():
|
for iface in root.iter():
|
||||||
if _local(iface.tag) != 'interface':
|
if _local(iface.tag) != 'interface':
|
||||||
continue
|
continue
|
||||||
@ -410,14 +394,12 @@ def _extract_params_wsdl20(root, op_name: str, elem_map, type_map) -> list:
|
|||||||
if _local(child.tag) == 'input':
|
if _local(child.tag) == 'input':
|
||||||
elem_ref = (child.get('element') or '').split(':')[-1]
|
elem_ref = (child.get('element') or '').split(':')[-1]
|
||||||
if elem_ref in elem_map:
|
if elem_ref in elem_map:
|
||||||
elem, elem_ns = elem_map[elem_ref]
|
return _parse_element(elem_map[elem_ref], elem_map, type_map)
|
||||||
return _parse_element(elem, elem_ns, elem_map, type_map)
|
|
||||||
|
|
||||||
# Naming-convention fallback
|
# Naming-convention fallback
|
||||||
for candidate in [op_name, op_name + 'Request', op_name + 'Input']:
|
for candidate in [op_name, op_name + 'Request', op_name + 'Input']:
|
||||||
if candidate in elem_map:
|
if candidate in elem_map:
|
||||||
elem, elem_ns = elem_map[candidate]
|
return _parse_element(elem_map[candidate], elem_map, type_map)
|
||||||
return _parse_element(elem, elem_ns, elem_map, type_map)
|
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -427,13 +409,9 @@ def _extract_params_wsdl20(root, op_name: str, elem_map, type_map) -> list:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _build_schema_maps(root: ET.Element, wsdl_ns: str) -> Tuple[Dict, Dict]:
|
def _build_schema_maps(root: ET.Element, wsdl_ns: str) -> Tuple[Dict, Dict]:
|
||||||
"""Build {name: (element, ns)} and {name: (complexType, ns)} maps from <types>.
|
"""Build {local_name: element} and {local_name: complexType} maps from <types>."""
|
||||||
|
elem_map: Dict[str, ET.Element] = {}
|
||||||
Each map value is a (ET.Element, targetNamespace) tuple so callers can
|
type_map: Dict[str, ET.Element] = {}
|
||||||
track which schema namespace every element / type belongs to.
|
|
||||||
"""
|
|
||||||
elem_map: Dict[str, Tuple[ET.Element, str]] = {}
|
|
||||||
type_map: Dict[str, Tuple[ET.Element, str]] = {}
|
|
||||||
|
|
||||||
types_el = root.find(_q(wsdl_ns, 'types'))
|
types_el = root.find(_q(wsdl_ns, 'types'))
|
||||||
if types_el is None:
|
if types_el is None:
|
||||||
@ -444,49 +422,47 @@ def _build_schema_maps(root: ET.Element, wsdl_ns: str) -> Tuple[Dict, Dict]:
|
|||||||
for node in types_el.iter():
|
for node in types_el.iter():
|
||||||
if _local(node.tag) != 'schema':
|
if _local(node.tag) != 'schema':
|
||||||
continue
|
continue
|
||||||
schema_ns = node.get('targetNamespace', '')
|
|
||||||
for child in node:
|
for child in node:
|
||||||
name = child.get('name', '')
|
name = child.get('name', '')
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
loc = _local(child.tag)
|
loc = _local(child.tag)
|
||||||
if loc == 'element':
|
if loc == 'element':
|
||||||
elem_map[name] = (child, schema_ns)
|
elem_map[name] = child
|
||||||
elif loc == 'complexType':
|
elif loc == 'complexType':
|
||||||
type_map[name] = (child, schema_ns)
|
type_map[name] = child
|
||||||
|
|
||||||
return elem_map, type_map
|
return elem_map, type_map
|
||||||
|
|
||||||
|
|
||||||
def _parse_element(elem: ET.Element, elem_ns: str, elem_map: Dict, type_map: Dict,
|
def _parse_element(elem: ET.Element, elem_map: Dict, type_map: Dict,
|
||||||
depth: int = 0) -> list:
|
depth: int = 0) -> List[Tuple[str, str]]:
|
||||||
"""Extract list[_Param] children from an xs:element."""
|
"""Extract [(name, hint)] from an xs:element (inline complexType or type=ref)."""
|
||||||
if depth > 4:
|
if depth > 4:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Inline complexType
|
# Inline complexType
|
||||||
ct = elem.find(_q(_XS, 'complexType'))
|
ct = elem.find(_q(_XS, 'complexType'))
|
||||||
if ct is not None:
|
if ct is not None:
|
||||||
return _parse_complex_type(ct, elem_ns, elem_map, type_map, depth)
|
return _parse_complex_type(ct, elem_map, type_map, depth)
|
||||||
|
|
||||||
# Named type reference
|
# Named type reference
|
||||||
type_ref = elem.get('type', '')
|
type_ref = elem.get('type', '')
|
||||||
type_local = type_ref.split(':')[-1] if type_ref else ''
|
type_local = type_ref.split(':')[-1] if type_ref else ''
|
||||||
if type_local:
|
if type_local:
|
||||||
if type_local in _XS_HINTS:
|
if type_local in _XS_HINTS:
|
||||||
return [] # simple scalar — not a parameter container
|
return [] # simple scalar — not a parameter container
|
||||||
entry = type_map.get(type_local)
|
ct = type_map.get(type_local)
|
||||||
if entry is not None:
|
if ct is not None:
|
||||||
ct, type_ns = entry
|
return _parse_complex_type(ct, elem_map, type_map, depth)
|
||||||
return _parse_complex_type(ct, type_ns, elem_map, type_map, depth)
|
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _parse_complex_type(ct: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
|
def _parse_complex_type(ct: ET.Element, elem_map: Dict, type_map: Dict,
|
||||||
depth: int = 0) -> list:
|
depth: int = 0) -> List[Tuple[str, str]]:
|
||||||
"""Extract list[_Param] from an xs:complexType."""
|
"""Extract [(name, hint)] from an xs:complexType."""
|
||||||
params: list = []
|
params: List[Tuple[str, str]] = []
|
||||||
|
|
||||||
# xs:complexContent / xs:extension (inheritance)
|
# xs:complexContent / xs:extension (inheritance)
|
||||||
cc = ct.find(_q(_XS, 'complexContent'))
|
cc = ct.find(_q(_XS, 'complexContent'))
|
||||||
@ -494,14 +470,13 @@ def _parse_complex_type(ct: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
|
|||||||
ext = cc.find(_q(_XS, 'extension'))
|
ext = cc.find(_q(_XS, 'extension'))
|
||||||
if ext is not None:
|
if ext is not None:
|
||||||
base_local = (ext.get('base') or '').split(':')[-1]
|
base_local = (ext.get('base') or '').split(':')[-1]
|
||||||
entry = type_map.get(base_local)
|
base_ct = type_map.get(base_local)
|
||||||
if entry is not None:
|
if base_ct is not None:
|
||||||
base_ct, base_ns = entry
|
params.extend(_parse_complex_type(base_ct, elem_map, type_map, depth + 1))
|
||||||
params.extend(_parse_complex_type(base_ct, base_ns, elem_map, type_map, depth + 1))
|
|
||||||
for tag in ('sequence', 'all', 'choice'):
|
for tag in ('sequence', 'all', 'choice'):
|
||||||
seq = ext.find(_q(_XS, tag))
|
seq = ext.find(_q(_XS, tag))
|
||||||
if seq is not None:
|
if seq is not None:
|
||||||
params.extend(_parse_sequence(seq, ns, elem_map, type_map, depth))
|
params.extend(_parse_sequence(seq, elem_map, type_map, depth))
|
||||||
break
|
break
|
||||||
return params
|
return params
|
||||||
|
|
||||||
@ -509,21 +484,16 @@ def _parse_complex_type(ct: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
|
|||||||
for tag in ('sequence', 'all', 'choice'):
|
for tag in ('sequence', 'all', 'choice'):
|
||||||
seq = ct.find(_q(_XS, tag))
|
seq = ct.find(_q(_XS, tag))
|
||||||
if seq is not None:
|
if seq is not None:
|
||||||
params.extend(_parse_sequence(seq, ns, elem_map, type_map, depth))
|
params.extend(_parse_sequence(seq, elem_map, type_map, depth))
|
||||||
break
|
break
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
def _parse_sequence(seq: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
|
def _parse_sequence(seq: ET.Element, elem_map: Dict, type_map: Dict,
|
||||||
depth: int = 0) -> list:
|
depth: int = 0) -> List[Tuple[str, str]]:
|
||||||
"""Extract list[_Param] from xs:sequence / xs:all / xs:choice.
|
"""Extract [(name, hint)] from xs:sequence / xs:all / xs:choice."""
|
||||||
|
params: List[Tuple[str, str]] = []
|
||||||
Complex child elements are kept as container _Param nodes (preserving the
|
|
||||||
wrapper element), rather than being flattened into the parent list.
|
|
||||||
Child elements of a referenced type carry that type's namespace.
|
|
||||||
"""
|
|
||||||
params: list = []
|
|
||||||
choice_optional = _local(seq.tag) == 'choice'
|
choice_optional = _local(seq.tag) == 'choice'
|
||||||
|
|
||||||
for child in seq:
|
for child in seq:
|
||||||
@ -539,33 +509,26 @@ def _parse_sequence(seq: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
|
|||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
optional = choice_optional or child.get('minOccurs', '1') == '0'
|
optional = choice_optional or child.get('minOccurs', '1') == '0'
|
||||||
type_ref = child.get('type', '')
|
type_ref = child.get('type', '')
|
||||||
type_local = type_ref.split(':')[-1] if type_ref else ''
|
type_local = type_ref.split(':')[-1] if type_ref else ''
|
||||||
|
|
||||||
if type_local and type_local in _XS_HINTS:
|
if type_local and type_local in _XS_HINTS:
|
||||||
params.append(_Param(name=name, ns=ns, hint=_hint(type_local, optional)))
|
params.append((name, _hint(type_local, optional)))
|
||||||
else:
|
else:
|
||||||
|
# Inline or referenced complex type — mark as [any] at this depth
|
||||||
inline_ct = child.find(_q(_XS, 'complexType'))
|
inline_ct = child.find(_q(_XS, 'complexType'))
|
||||||
if inline_ct is not None and depth < 3:
|
if inline_ct is not None and depth < 2:
|
||||||
sub = _parse_complex_type(inline_ct, ns, elem_map, type_map, depth + 1)
|
sub = _parse_complex_type(inline_ct, elem_map, type_map, depth + 1)
|
||||||
if sub:
|
params.extend(sub) if sub else params.append((name, _hint('anyType', optional)))
|
||||||
params.append(_Param(name=name, ns=ns, children=sub))
|
elif type_local and type_local in type_map and depth < 2:
|
||||||
else:
|
sub = _parse_complex_type(type_map[type_local], elem_map, type_map, depth + 1)
|
||||||
params.append(_Param(name=name, ns=ns, hint=_hint('anyType', optional)))
|
params.extend(sub) if sub else params.append((name, _hint('anyType', optional)))
|
||||||
elif type_local and type_local in type_map and depth < 3:
|
|
||||||
child_ct, child_ns = type_map[type_local]
|
|
||||||
sub = _parse_complex_type(child_ct, child_ns, elem_map, type_map, depth + 1)
|
|
||||||
if sub:
|
|
||||||
# Keep wrapper element; children carry child_ns namespace
|
|
||||||
params.append(_Param(name=name, ns=ns, children=sub))
|
|
||||||
else:
|
|
||||||
params.append(_Param(name=name, ns=ns, hint=_hint('anyType', optional)))
|
|
||||||
else:
|
else:
|
||||||
params.append(_Param(name=name, ns=ns, hint=_hint('anyType', optional)))
|
params.append((name, _hint('anyType', optional)))
|
||||||
|
|
||||||
elif loc in ('sequence', 'all', 'choice') and depth < 4:
|
elif loc in ('sequence', 'all', 'choice') and depth < 3:
|
||||||
params.extend(_parse_sequence(child, ns, elem_map, type_map, depth + 1))
|
params.extend(_parse_sequence(child, elem_map, type_map, depth + 1))
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
@ -574,109 +537,18 @@ def _parse_sequence(seq: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
|
|||||||
# SOAP envelope builder
|
# SOAP envelope builder
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _assign_ns_prefix(ns: str, ns_to_pfx: Dict[str, str], used_pfx: set) -> str:
|
|
||||||
"""Return an existing or newly-assigned XML prefix for *ns*."""
|
|
||||||
if ns in ns_to_pfx:
|
|
||||||
return ns_to_pfx[ns]
|
|
||||||
|
|
||||||
# Check well-known namespaces first (prefix-match)
|
|
||||||
candidate = ''
|
|
||||||
for known_ns, known_pfx in _KNOWN_NS_PREFIXES.items():
|
|
||||||
if ns.startswith(known_ns):
|
|
||||||
candidate = known_pfx
|
|
||||||
break
|
|
||||||
|
|
||||||
if not candidate:
|
|
||||||
# Derive a short name from the last meaningful URL path segment
|
|
||||||
last = ns.rstrip('/').rsplit('/', 1)[-1]
|
|
||||||
base = ''.join(c for c in last.lower() if c.isalpha())[:4]
|
|
||||||
_generic = {'org', 'com', 'net', 'gov', 'www', 'http', 'wsdl', 'soap', ''}
|
|
||||||
if base in _generic:
|
|
||||||
parts = ns.rstrip('/').split('/')
|
|
||||||
for part in reversed(parts):
|
|
||||||
seg = ''.join(c for c in part.lower() if c.isalpha())[:4]
|
|
||||||
if seg and seg not in _generic:
|
|
||||||
base = seg
|
|
||||||
break
|
|
||||||
candidate = base or 'ns'
|
|
||||||
|
|
||||||
# Ensure uniqueness
|
|
||||||
orig, i = candidate, 1
|
|
||||||
while candidate in used_pfx:
|
|
||||||
candidate = f'{orig}{i}'
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
ns_to_pfx[ns] = candidate
|
|
||||||
used_pfx.add(candidate)
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _build_envelope(op_name: str, target_ns: str, soap_version: str,
|
def _build_envelope(op_name: str, target_ns: str, soap_version: str,
|
||||||
params=None) -> str:
|
params: Optional[List[Tuple[str, str]]] = None) -> str:
|
||||||
env_ns = _ENV12 if soap_version == '1.2' else _ENV11
|
env_ns = _ENV12 if soap_version == '1.2' else _ENV11
|
||||||
|
|
||||||
if params:
|
if params:
|
||||||
# --- collect all unique namespaces in tree order ---
|
# Default-namespace style → parameters inherit namespace, no prefix needed
|
||||||
ns_order: List[str] = []
|
ns_attr = f' xmlns="{target_ns}"' if target_ns else ''
|
||||||
ns_seen: set = set()
|
lines = [f' <{op_name}{ns_attr}>']
|
||||||
|
for pname, phint in params:
|
||||||
def _collect_ns(ps):
|
lines.append(f' <{pname}>{phint}</{pname}>')
|
||||||
for p in ps:
|
lines.append(f' </{op_name}>')
|
||||||
if p.ns and p.ns not in ns_seen:
|
body = '\n'.join(lines)
|
||||||
ns_order.append(p.ns)
|
|
||||||
ns_seen.add(p.ns)
|
|
||||||
_collect_ns(p.children)
|
|
||||||
|
|
||||||
if target_ns and target_ns not in ns_seen:
|
|
||||||
ns_order.append(target_ns)
|
|
||||||
ns_seen.add(target_ns)
|
|
||||||
_collect_ns(params)
|
|
||||||
|
|
||||||
# --- assign prefixes ---
|
|
||||||
ns_to_pfx: Dict[str, str] = {}
|
|
||||||
used_pfx: set = set()
|
|
||||||
|
|
||||||
# Target namespace always gets 'tns' (consistent with the no-params branch)
|
|
||||||
if target_ns:
|
|
||||||
ns_to_pfx[target_ns] = 'tns'
|
|
||||||
used_pfx.add('tns')
|
|
||||||
|
|
||||||
for ns in ns_order:
|
|
||||||
if ns not in ns_to_pfx:
|
|
||||||
_assign_ns_prefix(ns, ns_to_pfx, used_pfx)
|
|
||||||
|
|
||||||
# --- namespace declarations on the operation element ---
|
|
||||||
ns_decls = ' '.join(
|
|
||||||
f'xmlns:{ns_to_pfx[ns]}="{ns}"'
|
|
||||||
for ns in ns_order
|
|
||||||
if ns in ns_to_pfx
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- recursive XML renderer ---
|
|
||||||
def _render(ps, indent: str) -> List[str]:
|
|
||||||
lines: List[str] = []
|
|
||||||
for p in ps:
|
|
||||||
pfx = ns_to_pfx.get(p.ns or target_ns, '')
|
|
||||||
tag = f'{pfx}:{p.name}' if pfx else p.name
|
|
||||||
if p.hint is not None:
|
|
||||||
lines.append(f'{indent}<{tag}>{p.hint}</{tag}>')
|
|
||||||
elif p.children:
|
|
||||||
lines.append(f'{indent}<{tag}>')
|
|
||||||
lines.extend(_render(p.children, indent + ' '))
|
|
||||||
lines.append(f'{indent}</{tag}>')
|
|
||||||
else:
|
|
||||||
lines.append(f'{indent}<{tag}/>')
|
|
||||||
return lines
|
|
||||||
|
|
||||||
op_pfx = ns_to_pfx.get(target_ns, '')
|
|
||||||
op_tag = f'{op_pfx}:{op_name}' if op_pfx else op_name
|
|
||||||
op_open = f'<{op_tag} {ns_decls}>' if ns_decls else f'<{op_tag}>'
|
|
||||||
|
|
||||||
body_lines = [f' {op_open}']
|
|
||||||
body_lines.extend(_render(params, ' '))
|
|
||||||
body_lines.append(f' </{op_tag}>')
|
|
||||||
body = '\n'.join(body_lines)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f'<?xml version="1.0" encoding="utf-8"?>\n'
|
f'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||||
f'<soap:Envelope xmlns:soap="{env_ns}">\n'
|
f'<soap:Envelope xmlns:soap="{env_ns}">\n'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user