Compare commits

..

No commits in common. "master" and "v0.9.2" have entirely different histories.

13 changed files with 291 additions and 1159 deletions

1
.gitignore vendored
View File

@ -25,4 +25,3 @@ __pycache__/
# Flatpak
.flatpak/
repo/
*.local.json

View File

@ -50,30 +50,6 @@
</screenshots>
<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">
<description translate="no">
<p>Version 0.9.2 release</p>

View File

@ -1,5 +1,5 @@
project('roster',
version: '0.10.0',
version: '0.9.2',
meson_version: '>= 1.0.0',
default_options: [ 'warning_level=2', 'werror=false', ],
)

View File

@ -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")

View File

@ -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] + ''

View File

@ -3,13 +3,7 @@
<requires lib="gtk" version="4.0"/>
<requires lib="Adw" version="1.0"/>
<menu id="sidebar_menu">
<section>
<item>
<attribute name="label">Add Project</attribute>
<attribute name="action">win.add-project</attribute>
</item>
</section>
<menu id="import_menu">
<section>
<item>
<attribute name="label">Import from OpenAPI / Swagger</attribute>
@ -19,153 +13,131 @@
<attribute name="label">Import from WSDL</attribute>
<attribute name="action">win.import-wsdl</attribute>
</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>
</menu>
<template class="RosterWindow" parent="AdwApplicationWindow">
<property name="default-width">1200</property>
<property name="default-height">800</property>
<property name="width-request">470</property>
<property name="content">
<object class="AdwToastOverlay" id="toast_overlay">
<property name="child">
<object class="AdwOverlaySplitView" id="split_view">
<property name="min-sidebar-width">200</property>
<property name="max-sidebar-width">320</property>
<object class="GtkPaned" id="main_pane">
<property name="orientation">horizontal</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 -->
<property name="sidebar">
<object class="AdwToolbarView">
<!-- LEFT: Sidebar Panel with AdwToolbarView -->
<property name="start-child">
<object class="AdwToolbarView">
<property name="width-request">200</property>
<!-- Sidebar Header Bar -->
<child type="top">
<object class="AdwHeaderBar">
<property name="show-end-title-buttons">False</property>
<property name="show-start-title-buttons">False</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label">Projects</property>
<!-- Sidebar Header Bar -->
<child type="top">
<object class="AdwHeaderBar">
<property name="show-title">False</property>
<property name="show-end-title-buttons">False</property>
<property name="show-start-title-buttons">False</property>
<child type="start">
<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>
<class name="title"/>
</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"/>
<class name="navigation-sidebar"/>
</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>
<class name="navigation-sidebar"/>
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</property>
<!-- RIGHT: Main Content Panel with AdwToolbarView -->
<property name="content">
<object class="AdwToolbarView">
<!-- RIGHT: Main Content Panel with AdwToolbarView -->
<property name="end-child">
<object class="AdwToolbarView">
<!-- Main Header Bar -->
<child type="top">
<object class="AdwHeaderBar">
<property name="show-title">False</property>
<property name="show-start-title-buttons">False</property>
<property name="show-end-title-buttons">False</property>
<!-- Main Header Bar -->
<child type="top">
<object class="AdwHeaderBar">
<property name="show-title">False</property>
<property name="show-start-title-buttons">False</property>
<property name="show-end-title-buttons">False</property>
<!-- Sidebar toggle (only visible when collapsed) -->
<child type="start">
<object class="GtkToggleButton" id="sidebar_toggle_button">
<property name="icon-name">sidebar-show-symbolic</property>
<property name="tooltip-text">Show Sidebar</property>
<style>
<class name="flat"/>
</style>
<binding name="visible">
<lookup name="collapsed">split_view</lookup>
</binding>
</object>
</child>
<!-- Left side buttons -->
<child type="start">
<object class="GtkButton" id="save_request_button">
<property name="icon-name">document-save-symbolic</property>
<property name="tooltip-text">Save Current Request (Ctrl+S)</property>
<signal name="clicked" handler="on_save_request_clicked"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
<!-- Left side buttons -->
<child type="start">
<object class="GtkButton" id="save_request_button">
<property name="icon-name">document-save-symbolic</property>
<property name="tooltip-text">Save Current Request (Ctrl+S)</property>
<signal name="clicked" handler="on_save_request_clicked"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
<child type="start">
<object class="GtkButton" id="export_request_button">
<property name="icon-name">export-symbolic</property>
<property name="tooltip-text">Export as cURL</property>
<signal name="clicked" handler="on_export_request_clicked"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
<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 -->
<child type="end">
<object class="GtkWindowControls">
<property name="side">end</property>
</object>
</child>
<!-- Right side buttons -->
<child type="end">
<object class="GtkBox">
<property name="spacing">6</property>
<!-- Right side buttons -->
<child type="end">
<!-- New Request Button -->
<child>
<object class="GtkButton" id="new_request_button">
<property name="icon-name">list-add-symbolic</property>
<property name="tooltip-text">New Request (Ctrl+T)</property>
@ -174,8 +146,27 @@
</style>
</object>
</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>
</child>
</object>
</child>
<!-- Tab Bar as separate top bar -->
<child type="top">
@ -258,14 +249,22 @@
</property>
</object>
</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>
<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>

View File

@ -48,8 +48,6 @@ roster_sources = [
'wsdl_import_dialog.py',
'openapi_importer.py',
'openapi_import_dialog.py',
'http_file_importer.py',
'http_file_import_dialog.py',
]
install_data(roster_sources, install_dir: moduledir)

View File

@ -80,24 +80,28 @@ class RequestTabWidget(Gtk.Box):
def _build_ui(self) -> None:
"""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.set_margin_start(12)
self.url_container.set_margin_end(12)
self.url_container.set_margin_top(12)
self.url_container.set_margin_bottom(12)
# Environment Selector (left-aligned, outside clamp)
if self.project_id:
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.set_margin_start(12)
self.env_separator.set_margin_end(12)
self.url_container.append(self.env_separator)
# URL bar (method, URL, send) - centered in clamp
url_clamp = Adw.Clamp(maximum_size=1000)
url_clamp.set_hexpand(True)
self.url_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
# Method Dropdown
self.method_dropdown = Gtk.DropDown()
methods = Gtk.StringList()
for method in ["GET", "POST", "PUT", "DELETE"]:
@ -107,12 +111,13 @@ class RequestTabWidget(Gtk.Box):
self.method_dropdown.set_enable_search(False)
self.url_box.append(self.method_dropdown)
# URL Entry
self.url_entry = Gtk.Entry()
self.url_entry.set_placeholder_text("Enter URL...")
self.url_entry.set_hexpand(True)
self.url_entry.add_css_class("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.set_tooltip_text("Send Request")
self.send_button.add_css_class("suggested-action")
@ -122,89 +127,63 @@ class RequestTabWidget(Gtk.Box):
self.url_container.append(url_clamp)
self.append(self.url_container)
# Build content panels
self.request_panel = self._create_request_panel()
self.response_panel = self._create_response_panel()
# Normal layout: horizontal split pane
self.split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
self.split_pane.set_vexpand(True)
self.split_pane.set_position(UI_PANE_REQUEST_RESPONSE_POSITION)
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")
# Horizontal Split: Request | Response
split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
split_pane.set_vexpand(True)
split_pane.set_position(UI_PANE_REQUEST_RESPONSE_POSITION)
split_pane.set_shrink_start_child(False)
split_pane.set_shrink_end_child(False)
split_pane.set_resize_start_child(True)
split_pane.set_resize_end_child(True)
# Request Panel
request_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.request_stack = Gtk.Stack()
self.request_stack.set_vexpand(True)
self.request_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
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()
# Body tab
self._build_body_tab()
# Scripts tab
self._build_scripts_tab()
# Build linked toggle buttons for each page
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
split_pane.set_start_child(request_box)
request_panel.append(self.request_tab_switcher)
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 Panel
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.set_halign(Gtk.Align.START)
response_switcher.set_valign(Gtk.Align.CENTER)
response_switcher.set_halign(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.set_vexpand(True)
self.response_main_paned.set_position(UI_PANE_RESPONSE_DETAILS_POSITION)
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_resize_start_child(True)
self.response_main_paned.set_resize_end_child(True)
# Response Stack
self.response_stack = Gtk.Stack()
self.response_stack.set_vexpand(True)
self.response_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
@ -213,6 +192,7 @@ class RequestTabWidget(Gtk.Box):
response_switcher.set_stack(self.response_stack)
self.response_main_paned.set_start_child(self.response_stack)
# Response headers
headers_scroll = Gtk.ScrolledWindow()
headers_scroll.set_vexpand(True)
self.response_headers_textview = Gtk.TextView()
@ -225,6 +205,7 @@ class RequestTabWidget(Gtk.Box):
headers_scroll.set_child(self.response_headers_textview)
self.response_stack.add_titled(headers_scroll, "headers", "Headers")
# Response body
body_scroll = Gtk.ScrolledWindow()
body_scroll.set_vexpand(True)
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_bottom_margin(12)
# Set up theme
self._setup_sourceview_theme()
# Set font
css_provider = Gtk.CssProvider()
css_provider.load_from_data(b"""
textview {
@ -254,19 +237,24 @@ class RequestTabWidget(Gtk.Box):
body_scroll.set_child(self.response_body_sourceview)
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.set_vexpand(True)
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_end_child(False)
self.results_paned.set_resize_start_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.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)
# Header
preprocessing_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
preprocessing_header.set_margin_start(12)
preprocessing_header.set_margin_end(12)
@ -278,16 +266,19 @@ class RequestTabWidget(Gtk.Box):
preprocessing_label.set_halign(Gtk.Align.START)
preprocessing_header.append(preprocessing_label)
# Spacer
preprocessing_spacer = Gtk.Box()
preprocessing_spacer.set_hexpand(True)
preprocessing_header.append(preprocessing_spacer)
# Status icon
self.preprocessing_status_icon = Gtk.Image()
self.preprocessing_status_icon.set_from_icon_name("object-select-symbolic")
preprocessing_header.append(self.preprocessing_status_icon)
self.preprocessing_results_container.append(preprocessing_header)
# Output text view (scrollable, resizable)
self.preprocessing_output_scroll = Gtk.ScrolledWindow()
self.preprocessing_output_scroll.set_vexpand(True)
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)
# Script Results Panel (resizable)
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)
# Header
results_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
results_header.set_margin_start(12)
results_header.set_margin_end(12)
@ -324,16 +318,19 @@ class RequestTabWidget(Gtk.Box):
results_label.set_halign(Gtk.Align.START)
results_header.append(results_label)
# Spacer
results_spacer = Gtk.Box()
results_spacer.set_hexpand(True)
results_header.append(results_spacer)
# Status icon
self.script_status_icon = Gtk.Image()
self.script_status_icon.set_from_icon_name("object-select-symbolic")
results_header.append(self.script_status_icon)
self.script_results_container.append(results_header)
# Output text view (scrollable, resizable)
self.script_output_scroll = Gtk.ScrolledWindow()
self.script_output_scroll.set_vexpand(True)
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.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)
# Add the main paned to response_box
response_box.append(self.response_main_paned)
bottom_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
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 Bar
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.add_css_class("heading")
status_box.append(self.status_label)
@ -381,95 +375,16 @@ class RequestTabWidget(Gtk.Box):
self.size_label = Gtk.Label(label="")
status_box.append(self.size_label)
bottom_bar.append(status_box)
response_box.append(bottom_bar)
# Spacer
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:
"""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)
split_pane.set_end_child(response_box)
toggle_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
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)
self.append(split_pane)
def _build_headers_tab(self) -> None:
"""Build the headers tab."""
@ -1012,11 +927,6 @@ class RequestTabWidget(Gtk.Box):
# Switch to body tab
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:
"""Display error in this tab's UI."""
self.status_label.set_text("Error")
@ -1032,10 +942,6 @@ class RequestTabWidget(Gtk.Box):
source_buffer.set_text(error)
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):
"""Display script execution results."""
from .script_executor import ScriptResult

View File

@ -72,8 +72,7 @@
<child>
<object class="GtkListBox" id="requests_listbox">
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-start">12</property>
<style>
<class name="navigation-sidebar"/>
</style>

View File

@ -11,9 +11,10 @@
<child>
<object class="GtkLabel" id="method_label">
<property name="xalign">0.5</property>
<property name="width-chars">6</property>
<style>
<class name="method-chip"/>
<class name="caption"/>
<class name="dim-label"/>
</style>
</object>
</child>

View File

@ -20,14 +20,6 @@
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')
class RequestItem(Gtk.Box):
@ -46,9 +38,7 @@ class RequestItem(Gtk.Box):
def __init__(self, saved_request):
super().__init__()
self.saved_request = saved_request
method = saved_request.request.method
self.method_label.set_text(method)
self.method_label.add_css_class(_METHOD_CSS.get(method, 'dim-label'))
self.method_label.set_text(saved_request.request.method)
self.name_label.set_text(saved_request.name)
# Add click gesture for loading

View File

@ -19,7 +19,7 @@
import gi
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
import logging
from .models import HttpRequest, HttpResponse, HistoryEntry, RequestTab
@ -38,7 +38,6 @@ from .icon_picker_dialog import IconPickerDialog
from .environments_dialog import EnvironmentsDialog
from .wsdl_import_dialog import WsdlImportDialog
from .openapi_import_dialog import OpenApiImportDialog
from .http_file_import_dialog import HttpFileImportDialog
from .request_tab_widget import RequestTabWidget
from .widgets.history_item import HistoryItem
from .widgets.project_item import ProjectItem
@ -63,12 +62,14 @@ class RosterWindow(Adw.ApplicationWindow):
tab_view = Gtk.Template.Child()
tab_bar = Gtk.Template.Child()
# Split view
split_view = Gtk.Template.Child()
sidebar_toggle_button = Gtk.Template.Child()
# Panes
main_pane = Gtk.Template.Child()
# Sidebar widgets
projects_listbox = Gtk.Template.Child()
add_project_button = Gtk.Template.Child()
import_menu_button = Gtk.Template.Child()
# History (hidden but kept for compatibility)
history_listbox = Gtk.Template.Child()
@ -100,17 +101,6 @@ class RosterWindow(Adw.ApplicationWindow):
# 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
self._setup_tab_system()
self._load_projects()
@ -122,12 +112,6 @@ class RosterWindow(Adw.ApplicationWindow):
# Create first 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:
"""Handle window close request - warn if there are unsaved changes."""
# Check if any tabs have unsaved changes
@ -176,15 +160,15 @@ class RosterWindow(Adw.ApplicationWindow):
/* AdwToolbarView handles header bar heights automatically */
/* Just add minimal custom styling for other elements */
/* Request tab switcher (Headers/Body/Scripts) */
.request-tab-switcher button {
/* Stack switchers styling (Headers/Body tabs) */
stackswitcher button {
padding: 6px 16px;
min-height: 32px;
border-radius: 6px;
margin: 0 2px;
}
.request-tab-switcher button:checked {
stackswitcher button:checked {
font-weight: 900;
}
@ -218,23 +202,6 @@ class RosterWindow(Adw.ApplicationWindow):
color: @accent_color;
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(
@ -414,10 +381,6 @@ class RosterWindow(Adw.ApplicationWindow):
# incorrectly marked all variables as undefined.
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
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.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.connect("activate", lambda a, p: self.on_import_openapi_clicked(None))
self.add_action(action)
@ -544,10 +503,6 @@ class RosterWindow(Adw.ApplicationWindow):
action.connect("activate", lambda a, p: self.on_import_wsdl_clicked(None))
self.add_action(action)
action = Gio.SimpleAction.new("import-http-file", None)
action.connect("activate", lambda a, p: self.on_import_http_file_clicked(None))
self.add_action(action)
def _on_send_clicked(self, widget):
"""Handle Send button click from a tab widget."""
# Clear previous preprocessing results
@ -977,6 +932,7 @@ class RosterWindow(Adw.ApplicationWindow):
item.connect('manage-environments-requested', self._on_manage_environments, project)
self.projects_listbox.append(item)
@Gtk.Template.Callback()
def on_add_project_clicked(self, button):
"""Show dialog to create project."""
dialog = Adw.AlertDialog()
@ -1466,22 +1422,6 @@ class RosterWindow(Adw.ApplicationWindow):
widget.original_request = None
widget.modified = True
def on_import_http_file_clicked(self, button):
"""Open the .http file import dialog."""
projects = self.project_manager.load_projects()
dialog = HttpFileImportDialog(self.project_manager, projects)
dialog.connect('import-completed', self._on_http_file_import_completed)
dialog.present(self)
def _on_http_file_import_completed(self, dialog, project_id: str):
"""Refresh sidebar after a successful .http file import."""
self._load_projects()
projects = self.project_manager.load_projects()
for p in projects:
if p.id == project_id:
self._show_toast(f"Imported {len(p.requests)} request(s) into '{p.name}'")
break
def on_import_wsdl_clicked(self, button):
"""Open the WSDL import dialog."""
projects = self.project_manager.load_projects()

View File

@ -51,14 +51,6 @@ _XS_HINTS: Dict[str, str] = {
'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:
return f'{{{ns}}}{tag}'
@ -73,19 +65,6 @@ def _hint(xs_local: str, optional: bool) -> str:
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
# ---------------------------------------------------------------------------
@ -225,7 +204,7 @@ def _parse_wsdl11(root: ET.Element) -> WsdlParseResult:
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)
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:
"""Return list[_Param] for the input of a WSDL 1.1 operation."""
def _extract_params_wsdl11(root, op_name: str, elem_map, type_map) -> List[Tuple[str, str]]:
"""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)
# 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:
return []
elem, elem_ns = elem_map[input_elem_name]
return _parse_element(elem, elem_ns, elem_map, type_map)
return _parse_element(elem_map[input_elem_name], elem_map, type_map)
def _find_input_elem_wsdl11(root, op_name: str) -> Optional[str]:
@ -324,12 +303,14 @@ def _parse_wsdl20(root: ET.Element) -> WsdlParseResult:
break
# Collect (soap_action) per operation from SOAP bindings
# binding_local_name → {op_local → soap_action}
binding_ops: Dict[str, Dict[str, str]] = {}
for binding in root.iter():
if _local(binding.tag) != 'binding':
continue
b_name = binding.get('name', '')
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())
if not is_soap:
is_soap = any(
@ -344,6 +325,7 @@ def _parse_wsdl20(root: ET.Element) -> WsdlParseResult:
if _local(child.tag) != 'operation':
continue
ref = (child.get('ref') or '').split(':')[-1]
# SOAPAction may be a namespaced attribute
action = (
child.get(_q(_WSDL2_SOAP, '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:
op_info = binding_ops[service_binding_local]
else:
# Merge all SOAP binding operations
for ops in binding_ops.values():
for op_name, action in ops.items():
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'
)
# Build schema maps
elem_map, type_map = _build_schema_maps(root, _WSDL2)
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:
"""Return list[_Param] for the input of a WSDL 2.0 operation."""
def _extract_params_wsdl20(root, op_name: str, elem_map, type_map) -> List[Tuple[str, str]]:
"""Return [(param_name, hint), …] for the input of a WSDL 2.0 operation."""
for iface in root.iter():
if _local(iface.tag) != 'interface':
continue
@ -410,14 +394,12 @@ def _extract_params_wsdl20(root, op_name: str, elem_map, type_map) -> list:
if _local(child.tag) == 'input':
elem_ref = (child.get('element') or '').split(':')[-1]
if elem_ref in elem_map:
elem, elem_ns = elem_map[elem_ref]
return _parse_element(elem, elem_ns, elem_map, type_map)
return _parse_element(elem_map[elem_ref], elem_map, type_map)
# Naming-convention fallback
for candidate in [op_name, op_name + 'Request', op_name + 'Input']:
if candidate in elem_map:
elem, elem_ns = elem_map[candidate]
return _parse_element(elem, elem_ns, elem_map, type_map)
return _parse_element(elem_map[candidate], elem_map, type_map)
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]:
"""Build {name: (element, ns)} and {name: (complexType, ns)} maps from <types>.
Each map value is a (ET.Element, targetNamespace) tuple so callers can
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]] = {}
"""Build {local_name: element} and {local_name: complexType} maps from <types>."""
elem_map: Dict[str, ET.Element] = {}
type_map: Dict[str, ET.Element] = {}
types_el = root.find(_q(wsdl_ns, 'types'))
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():
if _local(node.tag) != 'schema':
continue
schema_ns = node.get('targetNamespace', '')
for child in node:
name = child.get('name', '')
if not name:
continue
loc = _local(child.tag)
if loc == 'element':
elem_map[name] = (child, schema_ns)
elem_map[name] = child
elif loc == 'complexType':
type_map[name] = (child, schema_ns)
type_map[name] = child
return elem_map, type_map
def _parse_element(elem: ET.Element, elem_ns: str, elem_map: Dict, type_map: Dict,
depth: int = 0) -> list:
"""Extract list[_Param] children from an xs:element."""
def _parse_element(elem: ET.Element, elem_map: Dict, type_map: Dict,
depth: int = 0) -> List[Tuple[str, str]]:
"""Extract [(name, hint)] from an xs:element (inline complexType or type=ref)."""
if depth > 4:
return []
# Inline complexType
ct = elem.find(_q(_XS, 'complexType'))
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
type_ref = elem.get('type', '')
type_ref = elem.get('type', '')
type_local = type_ref.split(':')[-1] if type_ref else ''
if type_local:
if type_local in _XS_HINTS:
return [] # simple scalar — not a parameter container
entry = type_map.get(type_local)
if entry is not None:
ct, type_ns = entry
return _parse_complex_type(ct, type_ns, elem_map, type_map, depth)
ct = type_map.get(type_local)
if ct is not None:
return _parse_complex_type(ct, elem_map, type_map, depth)
return []
def _parse_complex_type(ct: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
depth: int = 0) -> list:
"""Extract list[_Param] from an xs:complexType."""
params: list = []
def _parse_complex_type(ct: ET.Element, elem_map: Dict, type_map: Dict,
depth: int = 0) -> List[Tuple[str, str]]:
"""Extract [(name, hint)] from an xs:complexType."""
params: List[Tuple[str, str]] = []
# xs:complexContent / xs:extension (inheritance)
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'))
if ext is not None:
base_local = (ext.get('base') or '').split(':')[-1]
entry = type_map.get(base_local)
if entry is not None:
base_ct, base_ns = entry
params.extend(_parse_complex_type(base_ct, base_ns, elem_map, type_map, depth + 1))
base_ct = type_map.get(base_local)
if base_ct is not None:
params.extend(_parse_complex_type(base_ct, elem_map, type_map, depth + 1))
for tag in ('sequence', 'all', 'choice'):
seq = ext.find(_q(_XS, tag))
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
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'):
seq = ct.find(_q(_XS, tag))
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
return params
def _parse_sequence(seq: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
depth: int = 0) -> list:
"""Extract list[_Param] from xs:sequence / xs:all / xs:choice.
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 = []
def _parse_sequence(seq: ET.Element, elem_map: Dict, type_map: Dict,
depth: int = 0) -> List[Tuple[str, str]]:
"""Extract [(name, hint)] from xs:sequence / xs:all / xs:choice."""
params: List[Tuple[str, str]] = []
choice_optional = _local(seq.tag) == 'choice'
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:
continue
optional = choice_optional or child.get('minOccurs', '1') == '0'
type_ref = child.get('type', '')
optional = choice_optional or child.get('minOccurs', '1') == '0'
type_ref = child.get('type', '')
type_local = type_ref.split(':')[-1] if type_ref else ''
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:
# Inline or referenced complex type — mark as [any] at this depth
inline_ct = child.find(_q(_XS, 'complexType'))
if inline_ct is not None and depth < 3:
sub = _parse_complex_type(inline_ct, ns, elem_map, type_map, depth + 1)
if sub:
params.append(_Param(name=name, ns=ns, children=sub))
else:
params.append(_Param(name=name, ns=ns, hint=_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)))
if inline_ct is not None and depth < 2:
sub = _parse_complex_type(inline_ct, elem_map, type_map, depth + 1)
params.extend(sub) if sub else params.append((name, _hint('anyType', optional)))
elif type_local and type_local in type_map and depth < 2:
sub = _parse_complex_type(type_map[type_local], elem_map, type_map, depth + 1)
params.extend(sub) if sub else params.append((name, _hint('anyType', optional)))
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:
params.extend(_parse_sequence(child, ns, elem_map, type_map, depth + 1))
elif loc in ('sequence', 'all', 'choice') and depth < 3:
params.extend(_parse_sequence(child, elem_map, type_map, depth + 1))
return params
@ -574,109 +537,18 @@ def _parse_sequence(seq: ET.Element, ns: str, elem_map: Dict, type_map: Dict,
# 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,
params=None) -> str:
params: Optional[List[Tuple[str, str]]] = None) -> str:
env_ns = _ENV12 if soap_version == '1.2' else _ENV11
if params:
# --- collect all unique namespaces in tree order ---
ns_order: List[str] = []
ns_seen: set = set()
def _collect_ns(ps):
for p in ps:
if p.ns and p.ns not in ns_seen:
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)
# Default-namespace style → parameters inherit namespace, no prefix needed
ns_attr = f' xmlns="{target_ns}"' if target_ns else ''
lines = [f' <{op_name}{ns_attr}>']
for pname, phint in params:
lines.append(f' <{pname}>{phint}</{pname}>')
lines.append(f' </{op_name}>')
body = '\n'.join(lines)
return (
f'<?xml version="1.0" encoding="utf-8"?>\n'
f'<soap:Envelope xmlns:soap="{env_ns}">\n'