Compare commits

..

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

33 changed files with 505 additions and 2892 deletions

1
.gitignore vendored
View File

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

View File

@ -12,7 +12,6 @@ A modern HTTP client for GNOME, built with GTK 4 and libadwaita.
- Environment variables with secure credential storage - Environment variables with secure credential storage
- JavaScript preprocessing and postprocessing scripts - JavaScript preprocessing and postprocessing scripts
- Export requests (cURL and more) - Export requests (cURL and more)
- **Git-based collaboration** — projects are stored as plain JSON files, one per request, making it easy to share and version-control your API collections with a team
- GNOME-native UI - GNOME-native UI
## Screenshots ## Screenshots
@ -69,22 +68,6 @@ Store API keys, passwords, and tokens securely in GNOME Keyring with one-click e
[Learn more about Sensitive Variables](https://git.bugsy.cz/beval/roster/wiki/Sensitive-Variables) [Learn more about Sensitive Variables](https://git.bugsy.cz/beval/roster/wiki/Sensitive-Variables)
### Git-Based Collaboration
Every project and request is stored as a plain JSON file under `~/.local/share/cz.bugsy.roster/projects/`. This makes it straightforward to share your API collections with a team:
```bash
# Put your Roster data directory under version control
cd ~/.local/share/cz.bugsy.roster/projects
git init
git remote add origin git@example.com:your-team/api-collections.git
git add .
git commit -m "Add API collections"
git push
```
Team members can then clone the repository into their own data directory and get the full set of projects and requests instantly. Changes to requests show up as clean, reviewable diffs — each request is a separate file, so there are no merge conflicts from unrelated edits.
### JavaScript Automation ### JavaScript Automation
Use preprocessing and postprocessing scripts to: Use preprocessing and postprocessing scripts to:

View File

@ -1,7 +1,7 @@
{ {
"id" : "cz.bugsy.roster", "id" : "cz.bugsy.roster",
"runtime" : "org.gnome.Platform", "runtime" : "org.gnome.Platform",
"runtime-version" : "50", "runtime-version" : "49",
"sdk" : "org.gnome.Sdk", "sdk" : "org.gnome.Sdk",
"command" : "roster", "command" : "roster",
"finish-args" : [ "finish-args" : [
@ -28,20 +28,6 @@
] ]
}, },
"modules" : [ "modules" : [
{
"name" : "python3-pyyaml",
"buildsystem" : "simple",
"build-commands" : [
"pip3 install --no-deps --prefix=/app pyyaml-6.0.3.tar.gz"
],
"sources" : [
{
"type" : "file",
"url" : "https://files.pythonhosted.org/packages/source/P/PyYAML/pyyaml-6.0.3.tar.gz",
"sha256" : "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"
}
]
},
{ {
"name" : "roster", "name" : "roster",
"builddir" : true, "builddir" : true,

View File

@ -11,15 +11,5 @@
<summary>Request timeout</summary> <summary>Request timeout</summary>
<description>Timeout for HTTP requests in seconds</description> <description>Timeout for HTTP requests in seconds</description>
</key> </key>
<key name="backup-folder" type="s">
<default>''</default>
<summary>Backup folder path</summary>
<description>Path to the folder where project data is exported as a backup</description>
</key>
<key name="backup-auto-export" type="b">
<default>false</default>
<summary>Auto-export on save</summary>
<description>Automatically export project data to the backup folder after each save</description>
</key>
</schema> </schema>
</schemalist> </schemalist>

View File

@ -43,75 +43,9 @@
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/history.png</image> <image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/history.png</image>
<caption>Request history panel</caption> <caption>Request history panel</caption>
</screenshot> </screenshot>
<screenshot>
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/load_openapi.png</image>
<caption>OpenAPI/Swagger import dialog with loaded operations</caption>
</screenshot>
</screenshots> </screenshots>
<releases> <releases>
<release version="0.11.0" date="2026-05-27">
<description translate="no">
<ul>
<li>Each request is now stored as a separate JSON file, making it easy to share and version-control API collections with Git</li>
<li>Existing data is migrated automatically on first launch; the original file is kept as a backup</li>
<li>New Backup section in Preferences: export project data to a folder, manually or automatically after every save</li>
<li>Method prefix (GET, POST, …) is no longer shown in request names — the colored chip already carries that information</li>
<li>Imported request names from OpenAPI and .http files no longer include the method prefix</li>
<li>Request names may now contain / and :</li>
</ul>
</description>
</release>
<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>
<ul>
<li>Fix console.error() not available in scripts</li>
</ul>
</description>
</release>
<release version="0.9.1" date="2026-05-12">
<description translate="no">
<p>Version 0.9.1 release</p>
<ul>
<li>Add OpenAPI/Swagger import (2.0 + 3.x) with JSON body templates</li>
<li>Merge import buttons into single popover menu</li>
<li>Update to GNOME Platform 50</li>
</ul>
</description>
</release>
<release version="0.9.0" date="2026-05-12">
<description translate="no">
<p>Version 0.9.0 release</p>
<ul>
<li>Add WSDL import for SOAP services</li>
</ul>
</description>
</release>
<release version="0.8.1" date="2026-01-15"> <release version="0.8.1" date="2026-01-15">
<description translate="no"> <description translate="no">
<p>Version 0.8.1 release</p> <p>Version 0.8.1 release</p>

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px">
<g fill="none" stroke="#222222" stroke-linecap="round" stroke-linejoin="round">
<!-- Rounded rectangle frame -->
<rect x="1.5" y="1.5" width="13" height="13" rx="2.25" ry="2.25" stroke-width="1.5"/>
<!-- Left curly brace { -->
<path stroke-width="1.6"
d="M 7 4.5 C 6.2 4.5 5.75 4.9 5.75 5.6 L 5.75 7 C 5.75 7.7 5.3 8 4.75 8
C 5.3 8 5.75 8.3 5.75 9 L 5.75 10.4 C 5.75 11.1 6.2 11.5 7 11.5"/>
<!-- Right curly brace } -->
<path stroke-width="1.6"
d="M 9 4.5 C 9.8 4.5 10.25 4.9 10.25 5.6 L 10.25 7 C 10.25 7.7 10.7 8 11.25 8
C 10.7 8 10.25 8.3 10.25 9 L 10.25 10.4 C 10.25 11.1 9.8 11.5 9 11.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 808 B

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 136 KiB

View File

@ -5,7 +5,7 @@
<template class="EnvironmentsDialog" parent="AdwDialog"> <template class="EnvironmentsDialog" parent="AdwDialog">
<property name="title">Manage Environments</property> <property name="title">Manage Environments</property>
<property name="content-width">1200</property> <property name="content-width">1100</property>
<property name="content-height">600</property> <property name="content-height">600</property>
<property name="follows-content-size">false</property> <property name="follows-content-size">false</property>
@ -18,7 +18,7 @@
</child> </child>
<property name="content"> <property name="content">
<object class="GtkScrolledWindow" id="scrolled_window"> <object class="GtkScrolledWindow">
<property name="vexpand">true</property> <property name="vexpand">true</property>
<child> <child>
<object class="AdwClamp"> <object class="AdwClamp">

View File

@ -34,7 +34,6 @@ class EnvironmentsDialog(Adw.Dialog):
table_container = Gtk.Template.Child() table_container = Gtk.Template.Child()
add_environment_button = Gtk.Template.Child() add_environment_button = Gtk.Template.Child()
scrolled_window = Gtk.Template.Child()
__gsignals__ = { __gsignals__ = {
'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()), 'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
@ -49,10 +48,6 @@ class EnvironmentsDialog(Adw.Dialog):
self.header_row = None self.header_row = None
self.data_rows = [] self.data_rows = []
self._populate_table() self._populate_table()
self.connect('map', self._scroll_to_start)
def _scroll_to_start(self, widget):
self.scrolled_window.get_hadjustment().set_value(0)
def _populate_table(self): def _populate_table(self):
"""Populate the transposed environment-variable table.""" """Populate the transposed environment-variable table."""

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 = _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,169 +3,128 @@
<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">
<section>
<item>
<attribute name="label">Add Project</attribute>
<attribute name="action">win.add-project</attribute>
</item>
</section>
<section>
<item>
<attribute name="label">Import from OpenAPI / Swagger</attribute>
<attribute name="action">win.import-openapi</attribute>
</item>
<item>
<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"> <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="GtkButton" id="import_wsdl_button">
<property name="icon-name">papyrus-vertical-symbolic</property>
<property name="tooltip-text">Import from WSDL</property>
<signal name="clicked" handler="on_import_wsdl_clicked"/>
<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 +133,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 +236,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>

View File

@ -82,7 +82,7 @@ class RosterApplication(Adw.Application):
def on_preferences_action(self, widget, _): def on_preferences_action(self, widget, _):
"""Callback for the app.preferences action.""" """Callback for the app.preferences action."""
window = self.props.active_window window = self.props.active_window
preferences = PreferencesDialog(history_manager=window.history_manager, project_manager=window.project_manager) preferences = PreferencesDialog(history_manager=window.history_manager)
preferences.set_transient_for(window) preferences.set_transient_for(window)
preferences.connect('history-cleared', lambda d: window._load_history()) preferences.connect('history-cleared', lambda d: window._load_history())
preferences.present() preferences.present()

View File

@ -34,7 +34,6 @@ roster_sources = [
'variable_substitution.py', 'variable_substitution.py',
'http_client.py', 'http_client.py',
'history_manager.py', 'history_manager.py',
'migration.py',
'project_manager.py', 'project_manager.py',
'secret_manager.py', 'secret_manager.py',
'tab_manager.py', 'tab_manager.py',
@ -47,10 +46,6 @@ roster_sources = [
'script_executor.py', 'script_executor.py',
'wsdl_importer.py', 'wsdl_importer.py',
'wsdl_import_dialog.py', '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) install_data(roster_sources, install_dir: moduledir)

View File

@ -1,115 +0,0 @@
# migration.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 json
import logging
import re
import shutil
from pathlib import Path
logger = logging.getLogger(__name__)
def _sanitize_name(name: str) -> str:
s = name.lower().strip()
s = re.sub(r'[^\w\s-]', '', s)
s = re.sub(r'[\s_]+', '-', s)
s = re.sub(r'-+', '-', s)
return s.strip('-') or 'unnamed'
def _unique_name(used: set, base: str) -> str:
if base not in used:
return base
i = 2
while f"{base}-{i}" in used:
i += 1
return f"{base}-{i}"
def needs_migration(projects_dir: Path, legacy_file: Path) -> bool:
return legacy_file.exists() and not projects_dir.exists()
def run_migration(legacy_file: Path, projects_dir: Path) -> bool:
"""
Read requests.json, write new per-file directory structure.
Atomic: writes to a tmp dir first, then renames.
On success renames requests.json requests.json.bak.
On failure leaves no partial state and returns False.
"""
tmp_dir = projects_dir.parent / 'projects.tmp'
try:
with open(legacy_file, 'r') as f:
data = json.load(f)
projects = data.get('projects', [])
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
tmp_dir.mkdir(parents=True)
used_project_dirs: set = set()
for project in projects:
proj_base = _sanitize_name(project.get('name', ''))
proj_dir_name = _unique_name(used_project_dirs, proj_base)
used_project_dirs.add(proj_dir_name)
proj_dir = tmp_dir / proj_dir_name
proj_dir.mkdir()
requests = project.get('requests', [])
request_order = []
used_req_basenames: set = set()
for req in requests:
req_base = _sanitize_name(req.get('name', ''))
req_basename = _unique_name(used_req_basenames, req_base)
used_req_basenames.add(req_basename)
req_filename = req_basename + '.json'
request_order.append(req_filename)
with open(proj_dir / req_filename, 'w') as f:
json.dump(req, f, indent=2)
project_meta = {k: v for k, v in project.items() if k != 'requests'}
project_meta['request_order'] = request_order
with open(proj_dir / '_project.json', 'w') as f:
json.dump(project_meta, f, indent=2)
# Atomic rename
tmp_dir.rename(projects_dir)
bak_file = legacy_file.parent / (legacy_file.name + '.bak')
legacy_file.rename(bak_file)
logger.info("Migration successful: %d projects migrated, backup at %s", len(projects), bak_file)
return True
except Exception as e:
logger.error("Migration failed: %s", e)
try:
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
except Exception:
pass
return False

View File

@ -1,381 +0,0 @@
# openapi_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
import gi
gi.require_version('Soup', '3.0')
from gi.repository import Adw, Gtk, GObject, GLib, Soup
from typing import List
from .openapi_importer import parse_openapi, build_http_request, OpenApiParseResult, OpenApiOperation
# Method → Adwaita accent CSS class
_METHOD_CLASS = {
'GET': 'accent',
'POST': 'success',
'PUT': 'warning',
'PATCH': 'warning',
'DELETE': 'error',
}
class OpenApiImportDialog(Adw.Dialog):
"""Two-step dialog: fetch OpenAPI URL → select operations → import."""
__gtype_name__ = 'OpenApiImportDialog'
__gsignals__ = {
'import-completed': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
}
def __init__(self, project_manager, existing_projects):
super().__init__()
self.set_title("Import from OpenAPI")
self.set_content_width(580)
self.set_content_height(560)
self._pm = project_manager
self._existing_projects = list(existing_projects)
self._parse_result: OpenApiParseResult | None = None
self._op_checkboxes: List[tuple[OpenApiOperation, Gtk.CheckButton]] = []
self._soup = Soup.Session.new()
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_url_page(), "url")
self._stack.add_named(self._make_ops_page(), "ops")
tv.set_content(self._stack)
ab = Gtk.ActionBar()
self._back_btn = Gtk.Button(label="Back")
self._back_btn.connect("clicked", lambda _: self._show_url_page())
ab.pack_start(self._back_btn)
self._fetch_btn = Gtk.Button(label="Fetch spec")
self._fetch_btn.add_css_class("suggested-action")
self._fetch_btn.connect("clicked", self._on_fetch)
ab.pack_end(self._fetch_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_url_page()
def _make_url_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("openapi-import-symbolic")
icon.set_pixel_size(64)
icon.add_css_class("dim-label")
box.append(icon)
title = Gtk.Label(label="Import from OpenAPI")
title.add_css_class("title-2")
box.append(title)
subtitle = Gtk.Label(
label="Enter the URL of an OpenAPI 2.0 (Swagger) or 3.x specification (JSON or YAML)"
)
subtitle.add_css_class("dim-label")
subtitle.set_wrap(True)
subtitle.set_justify(Gtk.Justification.CENTER)
box.append(subtitle)
grp = Adw.PreferencesGroup()
self._url_row = Adw.EntryRow()
self._url_row.set_title("Spec URL")
self._url_row.set_input_purpose(Gtk.InputPurpose.URL)
self._url_row.connect("entry-activated", lambda _: self._on_fetch(None))
grp.add(self._url_row)
box.append(grp)
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)
self._spinner = Gtk.Spinner()
self._spinner.set_size_request(32, 32)
self._spinner.set_halign(Gtk.Align.CENTER)
self._spinner.set_visible(False)
box.append(self._spinner)
return box
def _make_ops_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)
# API info
api_grp = Adw.PreferencesGroup()
api_grp.set_title("API")
self._api_url_row = Adw.ActionRow()
self._api_url_row.set_title("Base URL")
api_grp.add(self._api_url_row)
outer.append(api_grp)
# Operations header
ops_hdr = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
ops_hdr.set_margin_top(2)
ops_lbl = Gtk.Label(label="Operations")
ops_lbl.add_css_class("title-4")
ops_lbl.set_hexpand(True)
ops_lbl.set_xalign(0)
ops_hdr.append(ops_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)
ops_hdr.append(self._sel_all_btn)
outer.append(ops_hdr)
# Scrollable operations list
scroll = Gtk.ScrolledWindow()
scroll.set_vexpand(True)
scroll.set_min_content_height(80)
self._ops_list = Gtk.ListBox()
self._ops_list.add_css_class("boxed-list")
self._ops_list.set_selection_mode(Gtk.SelectionMode.NONE)
scroll.set_child(self._ops_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_url_page(self):
self._stack.set_visible_child_name("url")
self._back_btn.set_visible(False)
self._fetch_btn.set_visible(True)
self._import_btn.set_visible(False)
def _show_ops_page(self):
self._stack.set_visible_child_name("ops")
self._back_btn.set_visible(True)
self._fetch_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._op_checkboxes)
new_state = not all_on
for _, cb in self._op_checkboxes:
cb.set_active(new_state)
self._sel_all_btn.set_label("Deselect All" if new_state else "Select All")
def _on_fetch(self, _btn):
url = self._url_row.get_text().strip()
if not url:
self._set_err("Please enter a spec URL")
return
if not url.startswith(('http://', 'https://')):
self._set_err("URL must start with http:// or https://")
return
self._err_label.set_visible(False)
self._spinner.set_visible(True)
self._spinner.start()
self._fetch_btn.set_sensitive(False)
try:
msg = Soup.Message.new("GET", url)
except Exception as e:
self._fetch_failed(f"Invalid URL: {e}")
return
msg.get_request_headers().append(
"Accept", "application/json, application/yaml, text/yaml, */*"
)
self._soup.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, None,
self._on_downloaded, msg)
def _on_downloaded(self, session, async_result, msg):
self._spinner.stop()
self._spinner.set_visible(False)
self._fetch_btn.set_sensitive(True)
try:
bytes_data = session.send_and_read_finish(async_result)
except GLib.Error as e:
self._set_err(f"Download failed: {e.message}")
return
status = msg.get_status()
if not (200 <= status < 300):
self._set_err(f"Server returned HTTP {status}")
return
content = bytes_data.get_data().decode('utf-8', errors='replace')
parsed = parse_openapi(content)
if parsed.error:
self._set_err(parsed.error)
return
self._parse_result = parsed
self._populate_ops_page(parsed)
self._show_ops_page()
def _on_import(self, _btn):
if not self._parse_result:
return
selected = [op for op, cb in self._op_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 op in selected:
http_req = build_http_request(op)
self._pm.add_request(project_id, op.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 _fetch_failed(self, msg: str):
self._spinner.stop()
self._spinner.set_visible(False)
self._fetch_btn.set_sensitive(True)
self._set_err(msg)
def _populate_ops_page(self, result: OpenApiParseResult):
self._api_url_row.set_subtitle(result.base_url or "Not specified")
while child := self._ops_list.get_first_child():
self._ops_list.remove(child)
self._op_checkboxes.clear()
for op in sorted(result.operations, key=lambda o: o.name.lower()):
row = Adw.ActionRow()
row.set_title(op.name)
# Subtitle: "METHOD /path" or description
if op.name == f'{op.method} {op.path}':
if op.description:
row.set_subtitle(op.description)
else:
sub = f'{op.method} {op.path}'
if op.description:
sub += f'{op.description}'
row.set_subtitle(sub)
# Method badge suffix
badge = Gtk.Label(label=op.method)
badge.add_css_class("caption")
badge.add_css_class("monospace")
css_cls = _METHOD_CLASS.get(op.method, 'dim-label')
badge.add_css_class(css_cls)
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._ops_list.append(row)
self._op_checkboxes.append((op, cb))
self._new_name_entry.set_text(result.api_title)
self._sel_all_btn.set_label("Deselect All")

View File

@ -1,395 +0,0 @@
# openapi_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 json
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
try:
import yaml as _yaml
_YAML_AVAILABLE = True
except ImportError:
_YAML_AVAILABLE = False
# JSON Schema type → representative default value
_TYPE_DEFAULTS: Dict[str, Any] = {
'string': '',
'integer': 0,
'number': 0.0,
'boolean': False,
'null': None,
'array': [],
'object': {},
}
_FORMAT_DEFAULTS: Dict[str, Any] = {
'date-time': '2024-01-01T00:00:00Z',
'date': '2024-01-01',
'time': '00:00:00',
'email': 'user@example.com',
'uuid': '00000000-0000-0000-0000-000000000000',
'uri': 'https://example.com',
'hostname': 'example.com',
'ipv4': '0.0.0.0',
'int32': 0,
'int64': 0,
'float': 0.0,
'double': 0.0,
'byte': '',
'binary': '',
'password': '',
}
HTTP_METHODS = frozenset({'get', 'post', 'put', 'delete', 'patch', 'head', 'options'})
# ---------------------------------------------------------------------------
# Public data classes
# ---------------------------------------------------------------------------
@dataclass
class OpenApiOperation:
name: str # operationId or "METHOD /path"
method: str # uppercase: GET, POST, …
path: str
url: str
headers: Dict[str, str]
body: str # JSON template or ''
description: str # summary / description
@dataclass
class OpenApiParseResult:
api_title: str
base_url: str
operations: List[OpenApiOperation] = field(default_factory=list)
error: Optional[str] = None
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def parse_openapi(content: str) -> OpenApiParseResult:
"""Parse OpenAPI 2.0 (Swagger) or 3.x from JSON or YAML."""
data = _load(content)
if isinstance(data, str): # error message
return OpenApiParseResult(api_title='', base_url='', error=data)
if not isinstance(data, dict):
return OpenApiParseResult(api_title='', base_url='',
error='Not a valid OpenAPI document')
if 'swagger' in data:
return _parse_v2(data)
elif 'openapi' in data:
return _parse_v3(data)
else:
return OpenApiParseResult(
api_title='', base_url='',
error='Not a recognized OpenAPI document (missing "swagger" or "openapi" key)'
)
def build_http_request(operation: OpenApiOperation):
"""Convert an OpenApiOperation into an HttpRequest."""
from .models import HttpRequest
syntax = 'JSON' if operation.body.lstrip().startswith('{') else 'RAW'
return HttpRequest(
method=operation.method,
url=operation.url,
headers=operation.headers,
body=operation.body,
syntax=syntax,
)
# ---------------------------------------------------------------------------
# Loader (JSON + optional YAML)
# ---------------------------------------------------------------------------
def _load(content: str):
"""Return parsed dict or an error string."""
content = content.strip()
# Try JSON first
try:
return json.loads(content)
except json.JSONDecodeError:
pass
# Fall back to YAML
if _YAML_AVAILABLE:
try:
return _yaml.safe_load(content)
except Exception as e:
return f'Invalid YAML: {e}'
# Looks like YAML but library not available
if not content.startswith('{'):
return ('YAML format detected but PyYAML is not available. '
'Please export the spec as JSON.')
return 'Invalid JSON'
# ---------------------------------------------------------------------------
# OpenAPI 2.0 (Swagger)
# ---------------------------------------------------------------------------
def _parse_v2(data: dict) -> OpenApiParseResult:
info = data.get('info', {})
title = info.get('title', 'API')
schemes = data.get('schemes', ['https'])
scheme = 'https' if 'https' in schemes else (schemes[0] if schemes else 'https')
host = data.get('host', 'localhost')
base_path = data.get('basePath', '/').rstrip('/')
base_url = f'{scheme}://{host}{base_path}'
definitions = data.get('definitions', {})
paths = data.get('paths', {})
operations = []
for path, path_item in paths.items():
if not isinstance(path_item, dict):
continue
path_params = path_item.get('parameters', [])
for method, op_data in path_item.items():
if method not in HTTP_METHODS or not isinstance(op_data, dict):
continue
params = _resolve_params(path_params + op_data.get('parameters', []),
data.get('parameters', {}))
operations.append(_make_op_v2(
method.upper(), path, base_url, op_data, params, definitions
))
if not operations:
return OpenApiParseResult(api_title=title, base_url=base_url,
error='No operations found in this document')
return OpenApiParseResult(api_title=title, base_url=base_url, operations=operations)
def _make_op_v2(method, path, base_url, op_data, params, definitions) -> OpenApiOperation:
op_id = op_data.get('operationId', '')
description = op_data.get('summary') or op_data.get('description', '')
name = op_id or path.lstrip('/')
url = base_url + path
url = _append_required_query(url, params)
headers: Dict[str, str] = {}
body = ''
if method in ('POST', 'PUT', 'PATCH', 'DELETE'):
body_params = [p for p in params if p.get('in') == 'body']
if body_params:
consumes = op_data.get('consumes', ['application/json'])
if any('json' in ct for ct in consumes):
headers['Content-Type'] = 'application/json'
schema = body_params[0].get('schema', {})
body = _schema_to_json(schema, definitions)
else:
headers['Content-Type'] = consumes[0] if consumes else 'application/octet-stream'
form_params = [p for p in params if p.get('in') == 'formData']
if form_params and not body:
headers['Content-Type'] = 'application/x-www-form-urlencoded'
body = '&'.join(f"{p['name']}=" for p in form_params)
produces = op_data.get('produces', ['application/json'])
if any('json' in ct for ct in produces):
headers.setdefault('Accept', 'application/json')
return OpenApiOperation(
name=name, method=method, path=path,
url=url, headers=headers, body=body, description=description,
)
# ---------------------------------------------------------------------------
# OpenAPI 3.x
# ---------------------------------------------------------------------------
def _parse_v3(data: dict) -> OpenApiParseResult:
info = data.get('info', {})
title = info.get('title', 'API')
servers = data.get('servers') or [{}]
base_url = servers[0].get('url', '').rstrip('/')
components = data.get('components', {})
schemas = components.get('schemas', {})
param_defs = components.get('parameters', {})
paths = data.get('paths', {})
operations = []
for path, path_item in paths.items():
if not isinstance(path_item, dict):
continue
path_params = _resolve_params(path_item.get('parameters', []), param_defs)
for method, op_data in path_item.items():
if method not in HTTP_METHODS or not isinstance(op_data, dict):
continue
params = _resolve_params(path_params + op_data.get('parameters', []),
param_defs)
operations.append(_make_op_v3(
method.upper(), path, base_url, op_data, params, schemas
))
if not operations:
return OpenApiParseResult(api_title=title, base_url=base_url,
error='No operations found in this document')
return OpenApiParseResult(api_title=title, base_url=base_url, operations=operations)
def _make_op_v3(method, path, base_url, op_data, params, schemas) -> OpenApiOperation:
op_id = op_data.get('operationId', '')
description = op_data.get('summary') or op_data.get('description', '')
name = op_id or path.lstrip('/')
url = base_url + path
url = _append_required_query(url, params)
headers: Dict[str, str] = {}
body = ''
req_body = op_data.get('requestBody', {})
if req_body and method in ('POST', 'PUT', 'PATCH', 'DELETE'):
content = req_body.get('content', {})
# Prefer application/json
json_ct = next(
(ct for ct in content if 'json' in ct),
None
)
if json_ct:
headers['Content-Type'] = json_ct
schema = content[json_ct].get('schema', {})
body = _schema_to_json(schema, schemas)
elif content:
ct = next(iter(content))
headers['Content-Type'] = ct
if 'form' in ct:
schema = content[ct].get('schema', {})
props = schema.get('properties', {})
body = '&'.join(f'{k}=' for k in props)
headers.setdefault('Accept', 'application/json')
return OpenApiOperation(
name=name, method=method, path=path,
url=url, headers=headers, body=body, description=description,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolve_params(params: list, param_defs: dict) -> list:
"""Resolve $ref entries in a parameter list."""
resolved = []
for p in params:
if isinstance(p, dict) and '$ref' in p:
ref_name = p['$ref'].split('/')[-1]
p = param_defs.get(ref_name, p)
resolved.append(p)
return resolved
def _append_required_query(url: str, params: list) -> str:
required = [p['name'] for p in params
if p.get('in') == 'query' and p.get('required', False)]
if required:
url += '?' + '&'.join(f'{n}=' for n in required)
return url
def _schema_to_json(schema: dict, defs: dict, depth: int = 0) -> str:
"""Return a pretty-printed JSON template string for the given schema."""
try:
value = _schema_value(schema, defs, depth)
return json.dumps(value, indent=2, ensure_ascii=False)
except Exception:
return ''
def _schema_value(schema: dict, defs: dict, depth: int = 0) -> Any:
"""Recursively build a representative value from a JSON Schema."""
if depth > 4:
return {}
# Resolve $ref
if '$ref' in schema:
ref_name = schema['$ref'].split('/')[-1]
schema = defs.get(ref_name, {})
if not schema:
return {}
# Combiners: allOf / anyOf / oneOf
for combiner in ('allOf', 'anyOf', 'oneOf'):
if combiner in schema:
merged: Dict[str, Any] = {}
for sub in schema[combiner]:
if '$ref' in sub:
sub = defs.get(sub['$ref'].split('/')[-1], {})
result = _schema_value(sub, defs, depth)
if isinstance(result, dict):
merged.update(result)
if merged:
return merged
# Non-object combiners: return first sub-value
first = schema[combiner][0] if schema[combiner] else {}
return _schema_value(first, defs, depth)
type_ = schema.get('type', 'object' if 'properties' in schema else None)
if type_ == 'object' or 'properties' in schema:
result = {}
required = schema.get('required', [])
for prop, prop_schema in schema.get('properties', {}).items():
result[prop] = _schema_value(prop_schema, defs, depth + 1)
if not result and 'additionalProperties' in schema:
add = schema['additionalProperties']
if isinstance(add, dict):
result['key'] = _schema_value(add, defs, depth + 1)
return result
if type_ == 'array':
items = schema.get('items', {})
return [_schema_value(items, defs, depth + 1)] if items else []
if type_ == 'string':
enum = schema.get('enum')
if enum:
return enum[0]
fmt = schema.get('format', '')
return _FORMAT_DEFAULTS.get(fmt, '')
if type_ in ('integer', 'number'):
return schema.get('minimum', 0)
if type_ == 'boolean':
return False
if type_ == 'null':
return None
return _TYPE_DEFAULTS.get(type_, {})

View File

@ -7,6 +7,7 @@
<property name="title" translatable="yes">Preferences</property> <property name="title" translatable="yes">Preferences</property>
<property name="modal">True</property> <property name="modal">True</property>
<property name="default-width">600</property> <property name="default-width">600</property>
<property name="default-height">400</property>
<child> <child>
<object class="AdwPreferencesPage"> <object class="AdwPreferencesPage">
@ -66,65 +67,6 @@
</child> </child>
</object> </object>
</child> </child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Data</property>
<property name="description" translatable="yes">Project storage and backup</property>
<child>
<object class="AdwActionRow" id="data_folder_row">
<property name="title" translatable="yes">Data folder</property>
<child>
<object class="GtkButton" id="open_folder_button">
<property name="label" translatable="yes">Open</property>
<property name="valign">center</property>
<signal name="clicked" handler="on_open_folder_clicked" swapped="no"/>
</object>
</child>
</object>
</child>
<child>
<object class="AdwActionRow" id="backup_folder_row">
<property name="title" translatable="yes">Backup folder</property>
<child>
<object class="GtkBox">
<property name="spacing">6</property>
<property name="valign">center</property>
<child>
<object class="GtkButton" id="choose_backup_folder_button">
<property name="label" translatable="yes">Choose…</property>
<signal name="clicked" handler="on_choose_backup_folder_clicked" swapped="no"/>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="AdwSwitchRow" id="auto_backup_row">
<property name="title" translatable="yes">Auto-backup on save</property>
<property name="subtitle" translatable="yes">Copy project files to the backup folder after each save</property>
</object>
</child>
<child>
<object class="AdwActionRow">
<property name="title" translatable="yes">Backup now</property>
<property name="subtitle" translatable="yes">Copy all project files to the backup folder</property>
<child>
<object class="GtkButton" id="backup_now_button">
<property name="label" translatable="yes">Backup</property>
<property name="valign">center</property>
<signal name="clicked" handler="on_backup_now_clicked" swapped="no"/>
</object>
</child>
</object>
</child>
</object>
</child>
</object> </object>
</child> </child>
</template> </template>

View File

@ -7,6 +7,7 @@
# the Free Software Foundation, either version 3 of the License, or # the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. # (at your option) any later version.
# #
#
# This program is distributed in the hope that it will be useful, # This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of # but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
@ -17,11 +18,8 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import logging
from gi.repository import Adw, Gtk, Gio, GObject from gi.repository import Adw, Gtk, Gio, GObject
logger = logging.getLogger(__name__)
@Gtk.Template(resource_path='/cz/bugsy/roster/preferences-dialog.ui') @Gtk.Template(resource_path='/cz/bugsy/roster/preferences-dialog.ui')
class PreferencesDialog(Adw.PreferencesWindow): class PreferencesDialog(Adw.PreferencesWindow):
@ -30,70 +28,41 @@ class PreferencesDialog(Adw.PreferencesWindow):
tls_verification_row = Gtk.Template.Child() tls_verification_row = Gtk.Template.Child()
timeout_row = Gtk.Template.Child() timeout_row = Gtk.Template.Child()
clear_history_button = Gtk.Template.Child() clear_history_button = Gtk.Template.Child()
data_folder_row = Gtk.Template.Child()
open_folder_button = Gtk.Template.Child()
backup_folder_row = Gtk.Template.Child()
choose_backup_folder_button = Gtk.Template.Child()
auto_backup_row = Gtk.Template.Child()
backup_now_button = Gtk.Template.Child()
__gsignals__ = { __gsignals__ = {
'history-cleared': (GObject.SIGNAL_RUN_FIRST, None, ()) 'history-cleared': (GObject.SIGNAL_RUN_FIRST, None, ())
} }
def __init__(self, history_manager=None, project_manager=None, **kwargs): def __init__(self, history_manager=None, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.history_manager = history_manager self.history_manager = history_manager
self.project_manager = project_manager
# Get settings
self.settings = Gio.Settings.new('cz.bugsy.roster') self.settings = Gio.Settings.new('cz.bugsy.roster')
# Bind settings to UI
self.settings.bind( self.settings.bind(
'force-tls-verification', 'force-tls-verification',
self.tls_verification_row, self.tls_verification_row,
'active', 'active',
Gio.SettingsBindFlags.DEFAULT Gio.SettingsBindFlags.DEFAULT
) )
self.settings.bind( self.settings.bind(
'request-timeout', 'request-timeout',
self.timeout_row, self.timeout_row,
'value', 'value',
Gio.SettingsBindFlags.DEFAULT Gio.SettingsBindFlags.DEFAULT
) )
self.settings.bind(
'backup-auto-export',
self.auto_backup_row,
'active',
Gio.SettingsBindFlags.DEFAULT
)
# Populate data folder path
if project_manager is not None:
self.data_folder_row.set_subtitle(str(project_manager.data_dir))
# Populate backup folder path
self._update_backup_folder_subtitle()
self.settings.connect('changed::backup-folder', lambda *_: self._update_backup_folder_subtitle())
# Disable backup buttons when no backup folder is set
self._update_backup_button_sensitivity()
self.settings.connect('changed::backup-folder', lambda *_: self._update_backup_button_sensitivity())
def _update_backup_folder_subtitle(self):
folder = self.settings.get_string('backup-folder')
self.backup_folder_row.set_subtitle(folder if folder else 'Not set')
def _update_backup_button_sensitivity(self):
has_folder = bool(self.settings.get_string('backup-folder'))
self.backup_now_button.set_sensitive(has_folder)
self.auto_backup_row.set_sensitive(has_folder)
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_clear_history_clicked(self, button): def on_clear_history_clicked(self, button):
"""Clear all history after confirmation."""
if not self.history_manager: if not self.history_manager:
return return
# Create confirmation dialog
dialog = Adw.AlertDialog.new( dialog = Adw.AlertDialog.new(
"Clear All History?", "Clear All History?",
"This will permanently delete all saved request and response history. This action cannot be undone." "This will permanently delete all saved request and response history. This action cannot be undone."
@ -106,59 +75,10 @@ class PreferencesDialog(Adw.PreferencesWindow):
def on_response(dialog, response): def on_response(dialog, response):
if response == "clear": if response == "clear":
# Clear the history
self.history_manager.clear_history() self.history_manager.clear_history()
# Notify the main window to refresh
self.emit('history-cleared') self.emit('history-cleared')
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@Gtk.Template.Callback()
def on_open_folder_clicked(self, button):
if self.project_manager is None:
return
Gio.AppInfo.launch_default_for_uri(
f"file://{self.project_manager.data_dir}", None
)
@Gtk.Template.Callback()
def on_choose_backup_folder_clicked(self, button):
chooser = Gtk.FileChooserNative.new(
"Choose Backup Folder",
self,
Gtk.FileChooserAction.SELECT_FOLDER,
"Select",
"Cancel"
)
current = self.settings.get_string('backup-folder')
if current:
try:
chooser.set_current_folder(Gio.File.new_for_path(current))
except Exception:
pass
def on_response(chooser, response):
if response == Gtk.ResponseType.ACCEPT:
folder = chooser.get_file()
if folder:
self.settings.set_string('backup-folder', folder.get_path())
chooser.connect('response', on_response)
chooser.show()
@Gtk.Template.Callback()
def on_backup_now_clicked(self, button):
if self.project_manager is None:
return
folder = self.settings.get_string('backup-folder')
if not folder:
return
try:
self.project_manager.export_to_folder(folder)
toast = Adw.Toast.new("Backup completed")
self.add_toast(toast)
except Exception as e:
logger.error("Backup failed: %s", e)
toast = Adw.Toast.new("Backup failed")
self.add_toast(toast)

View File

@ -19,7 +19,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import json import json
import shutil
import uuid import uuid
import logging import logging
from pathlib import Path from pathlib import Path
@ -30,300 +29,85 @@ gi.require_version('GLib', '2.0')
from gi.repository import GLib from gi.repository import GLib
from .models import Project, SavedRequest, HttpRequest, Environment from .models import Project, SavedRequest, HttpRequest, Environment
from .secret_manager import get_secret_manager from .secret_manager import get_secret_manager
from .migration import _sanitize_name, needs_migration, run_migration
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _unique_filename(base: str, exclude: set) -> str:
"""Return a unique .json filename whose basename is not in `exclude`."""
if base not in exclude:
return base + '.json'
i = 2
while f"{base}-{i}" in exclude:
i += 1
return f"{base}-{i}.json"
class ProjectManager: class ProjectManager:
"""Manages project and saved request persistence.""" """Manages project and saved request persistence."""
_INDEX_FILE = '_index.json'
def __init__(self): def __init__(self):
# Use XDG data directory (works for both Flatpak and native)
# Flatpak: ~/.var/app/cz.bugsy.roster/data/cz.bugsy.roster
# Native: ~/.local/share/cz.bugsy.roster
self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.bugsy.roster' self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.bugsy.roster'
self.projects_dir = self.data_dir / 'projects' self.projects_file = self.data_dir / 'requests.json'
self.legacy_file = self.data_dir / 'requests.json'
self._ensure_data_dir() self._ensure_data_dir()
# In-memory cache for projects to reduce disk I/O
self._projects_cache: Optional[List[Project]] = None self._projects_cache: Optional[List[Project]] = None
# project_id → directory name under projects_dir
self._project_dirs: dict = {}
# request_id → filename (e.g. "get-users.json")
self._request_filenames: dict = {}
self._legacy_mode = False
if needs_migration(self.projects_dir, self.legacy_file):
if not run_migration(self.legacy_file, self.projects_dir):
logger.error("Migration failed falling back to legacy mode")
self._legacy_mode = True
def _ensure_data_dir(self): def _ensure_data_dir(self):
"""Create data directory if it doesn't exist."""
self.data_dir.mkdir(parents=True, exist_ok=True) self.data_dir.mkdir(parents=True, exist_ok=True)
# ===== Loading =====
def load_projects(self, force_reload: bool = False) -> List[Project]: def load_projects(self, force_reload: bool = False) -> List[Project]:
"""
Load projects from JSON file with in-memory caching.
Args:
force_reload: If True, bypass cache and reload from disk
Returns:
List of Project objects
"""
# Return cached data if available and not forcing reload
if self._projects_cache is not None and not force_reload: if self._projects_cache is not None and not force_reload:
return self._projects_cache return self._projects_cache
if self._legacy_mode or not self.projects_dir.exists(): # Load from disk
return self._load_legacy() if not self.projects_file.exists():
return self._load_from_dirs()
def _load_project_order(self) -> List[str]:
"""Load project order from _index.json. Returns list of project IDs."""
index_file = self.projects_dir / self._INDEX_FILE
if not index_file.exists():
return []
try:
with open(index_file, 'r') as f:
return json.load(f)
except Exception as e:
logger.error("Error loading project order: %s", e)
return []
def _save_project_order(self, project_ids: List[str]):
"""Save project order to _index.json."""
index_file = self.projects_dir / self._INDEX_FILE
try:
with open(index_file, 'w') as f:
json.dump(project_ids, f, indent=2)
except Exception as e:
logger.error("Error saving project order: %s", e)
def _load_from_dirs(self) -> List[Project]:
projects_by_id = {}
self._project_dirs.clear()
self._request_filenames.clear()
try:
entries = sorted(self.projects_dir.iterdir())
except Exception as e:
logger.error("Cannot iterate projects dir: %s", e)
self._projects_cache = [] self._projects_cache = []
return [] return []
for proj_dir in entries:
if not proj_dir.is_dir():
continue
meta_file = proj_dir / '_project.json'
if not meta_file.exists():
continue
try:
with open(meta_file, 'r') as f:
meta = json.load(f)
request_order = meta.get('request_order', [])
requests = []
loaded_files: set = set()
for filename in request_order:
req_file = proj_dir / filename
if not req_file.exists():
continue
try:
with open(req_file, 'r') as f:
req_data = json.load(f)
req = SavedRequest.from_dict(req_data)
requests.append(req)
self._request_filenames[req.id] = filename
loaded_files.add(filename)
except Exception as e:
logger.error("Error loading request %s: %s", req_file, e)
# Also pick up files not listed in request_order
for req_file in sorted(proj_dir.iterdir()):
if req_file.name.startswith('_') or req_file.suffix != '.json':
continue
if req_file.name in loaded_files:
continue
try:
with open(req_file, 'r') as f:
req_data = json.load(f)
req = SavedRequest.from_dict(req_data)
requests.append(req)
self._request_filenames[req.id] = req_file.name
except Exception as e:
logger.error("Error loading extra request %s: %s", req_file, e)
project = Project(
id=meta['id'],
name=meta['name'],
requests=requests,
created_at=meta['created_at'],
icon=meta.get('icon', 'folder-symbolic'),
variable_names=meta.get('variable_names', []),
environments=[Environment.from_dict(e) for e in meta.get('environments', [])],
sensitive_variables=meta.get('sensitive_variables', []),
)
projects_by_id[project.id] = project
self._project_dirs[project.id] = proj_dir.name
except Exception as e:
logger.error("Error loading project %s: %s", proj_dir, e)
# Apply saved order, then append any projects not yet in the order file
ordered_ids = self._load_project_order()
projects = []
seen: set = set()
for pid in ordered_ids:
if pid in projects_by_id and pid not in seen:
projects.append(projects_by_id[pid])
seen.add(pid)
for pid, project in projects_by_id.items():
if pid not in seen:
projects.append(project)
self._projects_cache = projects
return projects
def _load_legacy(self) -> List[Project]:
if not self.legacy_file.exists():
self._projects_cache = []
return []
try: try:
with open(self.legacy_file, 'r') as f: with open(self.projects_file, 'r') as f:
data = json.load(f) data = json.load(f)
self._projects_cache = [Project.from_dict(p) for p in data.get('projects', [])] self._projects_cache = [Project.from_dict(p) for p in data.get('projects', [])]
return self._projects_cache return self._projects_cache
except Exception as e: except Exception as e:
logger.error("Error loading legacy projects: %s", e) logger.error(f"Error loading projects: {e}")
self._projects_cache = [] self._projects_cache = []
return [] return []
# ===== Saving ===== def save_projects(self, projects: List[Project]):
"""Save projects to JSON file and update cache."""
def _save_project(self, project: Project):
if self._legacy_mode:
self._save_all_legacy()
return
try: try:
old_dir_name = self._project_dirs.get(project.id) data = {
new_dir_name = _sanitize_name(project.name) 'version': 1,
'projects': [p.to_dict() for p in projects]
# Handle project rename
if old_dir_name and old_dir_name != new_dir_name:
old_dir = self.projects_dir / old_dir_name
if old_dir.exists():
# Ensure new name is unique
candidate = new_dir_name
i = 2
while (self.projects_dir / candidate).exists():
candidate = f"{new_dir_name}-{i}"
i += 1
new_dir_name = candidate
old_dir.rename(self.projects_dir / new_dir_name)
project_dir = self.projects_dir / new_dir_name
project_dir.mkdir(parents=True, exist_ok=True)
self._project_dirs[project.id] = new_dir_name
# Assign filenames preserving stable names where possible
request_order = []
used_basenames: set = set()
for req in project.requests:
old_filename = self._request_filenames.get(req.id)
desired_base = _sanitize_name(req.name)
if old_filename is not None:
old_base = old_filename[:-5] if old_filename.endswith('.json') else old_filename
if old_base == desired_base and old_base not in used_basenames:
filename = old_filename
else:
filename = _unique_filename(desired_base, used_basenames)
else:
filename = _unique_filename(desired_base, used_basenames)
used_basenames.add(filename[:-5] if filename.endswith('.json') else filename)
request_order.append(filename)
self._request_filenames[req.id] = filename
# Write request files
for req, filename in zip(project.requests, request_order):
with open(project_dir / filename, 'w') as f:
json.dump(req.to_dict(), f, indent=2)
# Remove orphaned request files
for existing in list(project_dir.iterdir()):
if existing.name.startswith('_') or existing.suffix != '.json':
continue
if existing.name not in request_order:
existing.unlink()
# Write project metadata
meta = {
'id': project.id,
'name': project.name,
'created_at': project.created_at,
'icon': project.icon,
'variable_names': project.variable_names,
'sensitive_variables': project.sensitive_variables,
'environments': [e.to_dict() for e in project.environments],
'request_order': request_order,
} }
with open(project_dir / '_project.json', 'w') as f: with open(self.projects_file, 'w') as f:
json.dump(meta, f, indent=2)
self._trigger_auto_backup()
except Exception as e:
logger.error("Error saving project %s: %s", project.name, e)
def _save_all_legacy(self):
"""Fallback: write all cached projects to the monolithic JSON file."""
try:
projects = self._projects_cache or []
data = {'version': 1, 'projects': [p.to_dict() for p in projects]}
with open(self.legacy_file, 'w') as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
# Update cache with the saved data
self._projects_cache = projects
except Exception as e: except Exception as e:
logger.error("Error saving projects (legacy): %s", e) logger.error(f"Error saving projects: {e}")
def _trigger_auto_backup(self):
try:
import gi
gi.require_version('Gio', '2.0')
from gi.repository import Gio
settings = Gio.Settings.new('cz.bugsy.roster')
if settings.get_boolean('backup-auto-export'):
folder = settings.get_string('backup-folder')
if folder:
self.export_to_folder(folder)
except Exception as e:
logger.error("Auto-backup failed: %s", e)
# ===== Backup / export =====
def export_to_folder(self, destination: str):
dest = Path(destination) / 'roster-backup'
shutil.copytree(self.projects_dir, dest, dirs_exist_ok=True)
# ===== Public API =====
def add_project(self, name: str) -> Project: def add_project(self, name: str) -> Project:
"""Create new project with default environment."""
projects = self.load_projects() projects = self.load_projects()
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
# Create default environment
default_env = Environment( default_env = Environment(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
name="Default", name="Default",
variables={}, variables={},
created_at=now created_at=now
) )
project = Project( project = Project(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
name=name, name=name,
@ -333,14 +117,11 @@ class ProjectManager:
environments=[default_env] environments=[default_env]
) )
projects.append(project) projects.append(project)
self._save_project(project) self.save_projects(projects)
if not self._legacy_mode:
order = self._load_project_order()
order.append(project.id)
self._save_project_order(order)
return project return project
def update_project(self, project_id: str, new_name: str = None, new_icon: str = None): def update_project(self, project_id: str, new_name: str = None, new_icon: str = None):
"""Update a project's name and/or icon."""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -348,33 +129,20 @@ class ProjectManager:
p.name = new_name p.name = new_name
if new_icon is not None: if new_icon is not None:
p.icon = new_icon p.icon = new_icon
self._save_project(p)
break break
self.save_projects(projects)
def delete_project(self, project_id: str, def delete_project(self, project_id: str,
callback: Optional[Callable[[], None]] = None): callback: Optional[Callable[[], None]] = None):
"""Delete a project and all its requests (async for secret cleanup)."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
for p in projects: # Remove project from list and save immediately
if p.id == project_id: projects = [p for p in projects if p.id != project_id]
if not self._legacy_mode: self.save_projects(projects)
dir_name = self._project_dirs.get(project_id)
if dir_name:
project_dir = self.projects_dir / dir_name
if project_dir.exists():
shutil.rmtree(project_dir)
self._project_dirs.pop(project_id, None)
break
self._projects_cache = [p for p in projects if p.id != project_id]
if self._legacy_mode:
self._save_all_legacy()
else:
order = self._load_project_order()
self._save_project_order([pid for pid in order if pid != project_id])
# Delete all secrets for this project asynchronously
def on_secrets_deleted(success): def on_secrets_deleted(success):
if callback: if callback:
callback() callback()
@ -382,6 +150,7 @@ class ProjectManager:
secret_manager.delete_all_project_secrets(project_id, on_secrets_deleted) secret_manager.delete_all_project_secrets(project_id, on_secrets_deleted)
def add_request(self, project_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest: def add_request(self, project_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest:
"""Add request to a project."""
projects = self.load_projects() projects = self.load_projects()
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
saved_request = SavedRequest( saved_request = SavedRequest(
@ -395,11 +164,12 @@ class ProjectManager:
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
p.requests.append(saved_request) p.requests.append(saved_request)
self._save_project(p)
break break
self.save_projects(projects)
return saved_request return saved_request
def find_request_by_name(self, project_id: str, name: str) -> Optional[SavedRequest]: def find_request_by_name(self, project_id: str, name: str) -> Optional[SavedRequest]:
"""Find a request by name within a project. Returns None if not found."""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -410,6 +180,7 @@ class ProjectManager:
return None return None
def update_request(self, project_id: str, request_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest: def update_request(self, project_id: str, request_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest:
"""Update an existing request."""
projects = self.load_projects() projects = self.load_projects()
updated_request = None updated_request = None
for p in projects: for p in projects:
@ -422,73 +193,29 @@ class ProjectManager:
req.modified_at = datetime.now(timezone.utc).isoformat() req.modified_at = datetime.now(timezone.utc).isoformat()
updated_request = req updated_request = req
break break
if updated_request:
self._save_project(p)
break break
if updated_request:
self.save_projects(projects)
return updated_request return updated_request
def delete_request(self, project_id: str, request_id: str): def delete_request(self, project_id: str, request_id: str):
"""Delete a saved request."""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
p.requests = [r for r in p.requests if r.id != request_id] p.requests = [r for r in p.requests if r.id != request_id]
self._request_filenames.pop(request_id, None)
self._save_project(p)
break break
self.save_projects(projects)
def reorder_project(self, project_id: str, direction: int):
"""Move a project up (direction=-1) or down (direction=+1)."""
projects = self.load_projects()
idx = next((i for i, p in enumerate(projects) if p.id == project_id), None)
if idx is None:
return
new_idx = idx + direction
if new_idx < 0 or new_idx >= len(projects):
return
projects[idx], projects[new_idx] = projects[new_idx], projects[idx]
self._projects_cache = projects
if not self._legacy_mode:
self._save_project_order([p.id for p in projects])
def reorder_request(self, project_id: str, request_id: str, direction: int):
"""Move a request up (direction=-1) or down (direction=+1) within its project."""
projects = self.load_projects()
for p in projects:
if p.id != project_id:
continue
idx = next((i for i, r in enumerate(p.requests) if r.id == request_id), None)
if idx is None:
return
new_idx = idx + direction
if new_idx < 0 or new_idx >= len(p.requests):
return
p.requests[idx], p.requests[new_idx] = p.requests[new_idx], p.requests[idx]
self._save_project(p)
return
def move_request_to_project(self, request_id: str, source_project_id: str, target_project_id: str):
"""Move a request from one project to another."""
projects = self.load_projects()
source = next((p for p in projects if p.id == source_project_id), None)
target = next((p for p in projects if p.id == target_project_id), None)
if not source or not target:
return
req = next((r for r in source.requests if r.id == request_id), None)
if not req:
return
# Write to target first so the file is never lost if source save fails
target.requests.append(req)
self._save_project(target)
source.requests = [r for r in source.requests if r.id != request_id]
self._save_project(source)
def add_environment(self, project_id: str, name: str) -> Environment: def add_environment(self, project_id: str, name: str) -> Environment:
"""Add environment to a project."""
projects = self.load_projects() projects = self.load_projects()
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
environment = None environment = None
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Create environment with empty values for all variables
variables = {var_name: "" for var_name in p.variable_names} variables = {var_name: "" for var_name in p.variable_names}
environment = Environment( environment = Environment(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@ -497,13 +224,14 @@ class ProjectManager:
created_at=now created_at=now
) )
p.environments.append(environment) p.environments.append(environment)
self._save_project(p)
break break
self.save_projects(projects)
return environment return environment
def copy_environment(self, project_id: str, source_env_id: str, new_name: str, def copy_environment(self, project_id: str, source_env_id: str, new_name: str,
callback: Optional[Callable] = None): callback: Optional[Callable] = None):
"""Copy an environment including all variables (sensitive ones via keyring)."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
@ -524,7 +252,7 @@ class ProjectManager:
created_at=now created_at=now
) )
p.environments.append(new_env) p.environments.append(new_env)
self._save_project(p) self.save_projects(projects)
sensitive_vars = [v for v in p.sensitive_variables if v in source_env.variables] sensitive_vars = [v for v in p.sensitive_variables if v in source_env.variables]
if not sensitive_vars: if not sensitive_vars:
@ -569,6 +297,7 @@ class ProjectManager:
callback(None) callback(None)
def update_environment(self, project_id: str, env_id: str, name: str = None, variables: dict = None): def update_environment(self, project_id: str, env_id: str, name: str = None, variables: dict = None):
"""Update an environment's name and/or variables."""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -579,20 +308,24 @@ class ProjectManager:
if variables is not None: if variables is not None:
env.variables = variables env.variables = variables
break break
self._save_project(p)
break break
self.save_projects(projects)
def delete_environment(self, project_id: str, env_id: str, def delete_environment(self, project_id: str, env_id: str,
callback: Optional[Callable[[], None]] = None): callback: Optional[Callable[[], None]] = None):
"""Delete an environment (async for secret cleanup)."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Remove environment from list
p.environments = [e for e in p.environments if e.id != env_id] p.environments = [e for e in p.environments if e.id != env_id]
self._save_project(p)
break break
self.save_projects(projects)
# Delete all secrets for this environment asynchronously
def on_secrets_deleted(success): def on_secrets_deleted(success):
if callback: if callback:
callback() callback()
@ -600,39 +333,47 @@ class ProjectManager:
secret_manager.delete_all_environment_secrets(project_id, env_id, on_secrets_deleted) secret_manager.delete_all_environment_secrets(project_id, env_id, on_secrets_deleted)
def add_variable(self, project_id: str, variable_name: str): def add_variable(self, project_id: str, variable_name: str):
"""Add a variable to the project and all environments."""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
if variable_name not in p.variable_names: if variable_name not in p.variable_names:
p.variable_names.append(variable_name) p.variable_names.append(variable_name)
# Add empty value to all environments
for env in p.environments: for env in p.environments:
env.variables[variable_name] = "" env.variables[variable_name] = ""
self._save_project(p)
break break
self.save_projects(projects)
def rename_variable(self, project_id: str, old_name: str, new_name: str, def rename_variable(self, project_id: str, old_name: str, new_name: str,
callback: Optional[Callable[[], None]] = None): callback: Optional[Callable[[], None]] = None):
"""Rename a variable in the project and all environments (async for secrets)."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
is_sensitive = False is_sensitive = False
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Update variable_names list
if old_name in p.variable_names: if old_name in p.variable_names:
idx = p.variable_names.index(old_name) idx = p.variable_names.index(old_name)
p.variable_names[idx] = new_name p.variable_names[idx] = new_name
# Check if sensitive and update list
if old_name in p.sensitive_variables: if old_name in p.sensitive_variables:
is_sensitive = True is_sensitive = True
idx_sensitive = p.sensitive_variables.index(old_name) idx_sensitive = p.sensitive_variables.index(old_name)
p.sensitive_variables[idx_sensitive] = new_name p.sensitive_variables[idx_sensitive] = new_name
# Update all environments
for env in p.environments: for env in p.environments:
if old_name in env.variables: if old_name in env.variables:
env.variables[new_name] = env.variables.pop(old_name) env.variables[new_name] = env.variables.pop(old_name)
self._save_project(p)
break break
self.save_projects(projects)
# Rename secrets in keyring if sensitive
if is_sensitive: if is_sensitive:
secret_manager.rename_variable_secrets(project_id, old_name, new_name, lambda success: callback() if callback else None) secret_manager.rename_variable_secrets(project_id, old_name, new_name, lambda success: callback() if callback else None)
elif callback: elif callback:
@ -640,6 +381,7 @@ class ProjectManager:
def delete_variable(self, project_id: str, variable_name: str, def delete_variable(self, project_id: str, variable_name: str,
callback: Optional[Callable[[], None]] = None): callback: Optional[Callable[[], None]] = None):
"""Delete a variable from the project and all environments (async for secrets)."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
is_sensitive = False is_sensitive = False
@ -647,19 +389,24 @@ class ProjectManager:
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Remove from variable_names
if variable_name in p.variable_names: if variable_name in p.variable_names:
p.variable_names.remove(variable_name) p.variable_names.remove(variable_name)
# Check if sensitive and get environments for cleanup
if variable_name in p.sensitive_variables: if variable_name in p.sensitive_variables:
is_sensitive = True is_sensitive = True
p.sensitive_variables.remove(variable_name) p.sensitive_variables.remove(variable_name)
environments_to_cleanup = [env.id for env in p.environments] environments_to_cleanup = [env.id for env in p.environments]
# Remove from all environments
for env in p.environments: for env in p.environments:
env.variables.pop(variable_name, None) env.variables.pop(variable_name, None)
self._save_project(p)
break break
self.save_projects(projects)
# Delete secrets for this variable asynchronously
if is_sensitive and environments_to_cleanup: if is_sensitive and environments_to_cleanup:
pending_count = [len(environments_to_cleanup)] pending_count = [len(environments_to_cleanup)]
@ -674,6 +421,7 @@ class ProjectManager:
callback() callback()
def update_environment_variable(self, project_id: str, env_id: str, variable_name: str, value: str): def update_environment_variable(self, project_id: str, env_id: str, variable_name: str, value: str):
"""Update a single variable value in an environment."""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -681,34 +429,58 @@ class ProjectManager:
if env.id == env_id: if env.id == env_id:
env.variables[variable_name] = value env.variables[variable_name] = value
break break
self._save_project(p)
break break
self.save_projects(projects)
def batch_update_environment_variables(self, project_id: str, env_id: str, variables: dict): def batch_update_environment_variables(self, project_id: str, env_id: str, variables: dict):
"""
Update multiple variables in an environment in one operation.
Args:
project_id: Project ID
env_id: Environment ID
variables: Dict of variable_name -> value pairs to update
"""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
for env in p.environments: for env in p.environments:
if env.id == env_id: if env.id == env_id:
# Update all variables
for var_name, var_value in variables.items(): for var_name, var_value in variables.items():
env.variables[var_name] = var_value env.variables[var_name] = var_value
break break
self._save_project(p)
break break
self.save_projects(projects)
# ===== Sensitive Variable Management ===== # ========== Sensitive Variable Management ==========
def mark_variable_as_sensitive(self, project_id: str, variable_name: str, def mark_variable_as_sensitive(self, project_id: str, variable_name: str,
callback: Optional[Callable[[bool], None]] = None): callback: Optional[Callable[[bool], None]] = None):
"""
Mark a variable as sensitive (move values from JSON to keyring) - async.
This will:
1. Add variable to sensitive_variables list
2. Move all environment values to keyring
3. Clear values from JSON (store empty string as placeholder)
Args:
project_id: Project ID
variable_name: Variable name to mark as sensitive
callback: Optional callback called with success boolean
"""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
secrets_to_store = [] secrets_to_store = []
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Add to sensitive list if not already there
if variable_name not in p.sensitive_variables: if variable_name not in p.sensitive_variables:
p.sensitive_variables.append(variable_name) p.sensitive_variables.append(variable_name)
# Collect all environment values to store in keyring
for env in p.environments: for env in p.environments:
if variable_name in env.variables: if variable_name in env.variables:
value = env.variables[variable_name] value = env.variables[variable_name]
@ -718,10 +490,12 @@ class ProjectManager:
'value': value, 'value': value,
'project_name': p.name 'project_name': p.name
}) })
# Clear from JSON (keep empty placeholder)
env.variables[variable_name] = "" env.variables[variable_name] = ""
self._save_project(p) self.save_projects(projects)
# Store all secrets asynchronously
if not secrets_to_store: if not secrets_to_store:
if callback: if callback:
callback(True) callback(True)
@ -754,6 +528,19 @@ class ProjectManager:
def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str, def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str,
callback: Optional[Callable[[bool], None]] = None): callback: Optional[Callable[[bool], None]] = None):
"""
Mark a variable as non-sensitive (move values from keyring to JSON) - async.
This will:
1. Remove variable from sensitive_variables list
2. Move all environment values from keyring to JSON
3. Delete values from keyring
Args:
project_id: Project ID
variable_name: Variable name to mark as non-sensitive
callback: Optional callback called with success boolean
"""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
@ -763,8 +550,10 @@ class ProjectManager:
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
target_project = p target_project = p
# Remove from sensitive list
if variable_name in p.sensitive_variables: if variable_name in p.sensitive_variables:
p.sensitive_variables.remove(variable_name) p.sensitive_variables.remove(variable_name)
environments_to_migrate = list(p.environments) environments_to_migrate = list(p.environments)
break break
@ -773,14 +562,17 @@ class ProjectManager:
callback(False) callback(False)
return return
# Retrieve all secrets, then update JSON, then delete from keyring
pending_count = [len(environments_to_migrate)] pending_count = [len(environments_to_migrate)]
all_success = [True] all_success = [True]
def on_single_migrate(env): def on_single_migrate(env):
def on_retrieve(value): def on_retrieve(value):
# Store in JSON
env.variables[variable_name] = value or "" env.variables[variable_name] = value or ""
self._save_project(target_project) self.save_projects(projects)
# Delete from keyring
def on_delete(success): def on_delete(success):
if not success: if not success:
all_success[0] = False all_success[0] = False
@ -805,6 +597,7 @@ class ProjectManager:
) )
def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool: def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool:
"""Check if a variable is marked as sensitive."""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -813,11 +606,24 @@ class ProjectManager:
def get_variable_value(self, project_id: str, env_id: str, variable_name: str, def get_variable_value(self, project_id: str, env_id: str, variable_name: str,
callback: Callable[[str], None]): callback: Callable[[str], None]):
"""
Get variable value from appropriate storage (keyring or JSON) - async.
This is a convenience method that handles both sensitive and non-sensitive variables.
Args:
project_id: Project ID
env_id: Environment ID
variable_name: Variable name
callback: Callback called with variable value (empty string if not found)
"""
projects = self.load_projects() projects = self.load_projects()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Check if sensitive
if variable_name in p.sensitive_variables: if variable_name in p.sensitive_variables:
# Retrieve from keyring asynchronously
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
def on_retrieve(value): def on_retrieve(value):
@ -831,6 +637,7 @@ class ProjectManager:
) )
return return
else: else:
# Retrieve from JSON synchronously
for env in p.environments: for env in p.environments:
if env.id == env_id: if env.id == env_id:
callback(env.variables.get(variable_name, "")) callback(env.variables.get(variable_name, ""))
@ -840,12 +647,26 @@ class ProjectManager:
def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str, def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str,
callback: Optional[Callable[[bool], None]] = None): callback: Optional[Callable[[bool], None]] = None):
"""
Set variable value in appropriate storage (keyring or JSON) - async.
This is a convenience method that handles both sensitive and non-sensitive variables.
Args:
project_id: Project ID
env_id: Environment ID
variable_name: Variable name
value: Value to set
callback: Optional callback called with success boolean
"""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Check if sensitive
if variable_name in p.sensitive_variables: if variable_name in p.sensitive_variables:
# Store in keyring asynchronously
for env in p.environments: for env in p.environments:
if env.id == env_id: if env.id == env_id:
secret_manager.store_secret( secret_manager.store_secret(
@ -857,15 +678,18 @@ class ProjectManager:
environment_name=env.name, environment_name=env.name,
callback=callback callback=callback
) )
# Keep empty placeholder in JSON
env.variables[variable_name] = "" env.variables[variable_name] = ""
self._save_project(p) self.save_projects(projects)
return return
else: else:
# Store in JSON synchronously
for env in p.environments: for env in p.environments:
if env.id == env_id: if env.id == env_id:
env.variables[variable_name] = value env.variables[variable_name] = value
break break
self._save_project(p)
self.save_projects(projects)
if callback: if callback:
callback(True) callback(True)
return return
@ -875,6 +699,17 @@ class ProjectManager:
def get_environment_with_secrets(self, project_id: str, env_id: str, def get_environment_with_secrets(self, project_id: str, env_id: str,
callback: Callable[[Optional[Environment]], None]): callback: Callable[[Optional[Environment]], None]):
"""
Get an environment with all variable values (including secrets from keyring) - async.
This is useful for variable substitution - it returns a complete Environment
object with all values populated from both JSON and keyring.
Args:
project_id: Project ID
env_id: Environment ID
callback: Callback called with Environment object (or None if not found)
"""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager() secret_manager = get_secret_manager()
@ -882,6 +717,7 @@ class ProjectManager:
if p.id == project_id: if p.id == project_id:
for env in p.environments: for env in p.environments:
if env.id == env_id: if env.id == env_id:
# Create a copy of the environment
complete_env = Environment( complete_env = Environment(
id=env.id, id=env.id,
name=env.name, name=env.name,
@ -889,10 +725,12 @@ class ProjectManager:
created_at=env.created_at created_at=env.created_at
) )
# If no sensitive variables, return immediately
if not p.sensitive_variables: if not p.sensitive_variables:
callback(complete_env) callback(complete_env)
return return
# Fill in sensitive variable values from keyring
def on_secrets_retrieved(secrets_dict): def on_secrets_retrieved(secrets_dict):
for var_name, value in secrets_dict.items(): for var_name, value in secrets_dict.items():
if value is not None: if value is not None:

View File

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

View File

@ -10,7 +10,6 @@
<file alias="icons/export-symbolic.svg">../data/icons/hicolor/symbolic/apps/export-symbolic.svg</file> <file alias="icons/export-symbolic.svg">../data/icons/hicolor/symbolic/apps/export-symbolic.svg</file>
<file alias="icons/wsdl-import-symbolic.svg">../data/icons/hicolor/symbolic/apps/wsdl-import-symbolic.svg</file> <file alias="icons/wsdl-import-symbolic.svg">../data/icons/hicolor/symbolic/apps/wsdl-import-symbolic.svg</file>
<file alias="icons/papyrus-vertical-symbolic.svg">../data/icons/hicolor/symbolic/apps/papyrus-vertical-symbolic.svg</file> <file alias="icons/papyrus-vertical-symbolic.svg">../data/icons/hicolor/symbolic/apps/papyrus-vertical-symbolic.svg</file>
<file alias="icons/openapi-import-symbolic.svg">../data/icons/hicolor/symbolic/apps/openapi-import-symbolic.svg</file>
<file preprocess="xml-stripblanks">widgets/header-row.ui</file> <file preprocess="xml-stripblanks">widgets/header-row.ui</file>
<file preprocess="xml-stripblanks">widgets/history-item.ui</file> <file preprocess="xml-stripblanks">widgets/history-item.ui</file>
<file preprocess="xml-stripblanks">widgets/project-item.ui</file> <file preprocess="xml-stripblanks">widgets/project-item.ui</file>

View File

@ -102,9 +102,6 @@ let consoleOutput = [];
const console = {{ const console = {{
log: function(...args) {{ log: function(...args) {{
consoleOutput.push(args.map(arg => String(arg)).join(' ')); consoleOutput.push(args.map(arg => String(arg)).join(' '));
}},
error: function(...args) {{
consoleOutput.push(args.map(arg => String(arg)).join(' '));
}} }}
}}; }};
@ -228,9 +225,6 @@ let consoleOutput = [];
const console = {{ const console = {{
log: function(...args) {{ log: function(...args) {{
consoleOutput.push(args.map(arg => String(arg)).join(' ')); consoleOutput.push(args.map(arg => String(arg)).join(' '));
}},
error: function(...args) {{
consoleOutput.push(args.map(arg => String(arg)).join(' '));
}} }}
}}; }};

View File

@ -4,16 +4,6 @@
<requires lib="libadwaita" version="1.0"/> <requires lib="libadwaita" version="1.0"/>
<menu id="project_menu"> <menu id="project_menu">
<section>
<item>
<attribute name="label">Move Up</attribute>
<attribute name="action">project.move-up</attribute>
</item>
<item>
<attribute name="label">Move Down</attribute>
<attribute name="action">project.move-down</attribute>
</item>
</section>
<section> <section>
<item> <item>
<attribute name="label">Manage Environments</attribute> <attribute name="label">Manage Environments</attribute>
@ -82,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>

View File

@ -41,17 +41,11 @@ class ProjectItem(Gtk.Box):
'request-load-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)), 'request-load-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
'request-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)), 'request-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
'manage-environments-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'manage-environments-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'request-move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
'request-move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
'request-move-to-project-requested': (GObject.SIGNAL_RUN_FIRST, None, (object, GObject.TYPE_STRING)),
} }
def __init__(self, project, other_projects=None): def __init__(self, project):
super().__init__() super().__init__()
self.project = project self.project = project
self.other_projects = other_projects or []
self.expanded = False self.expanded = False
self._setup_actions() self._setup_actions()
self._populate() self._populate()
@ -65,14 +59,6 @@ class ProjectItem(Gtk.Box):
"""Setup action group for menu.""" """Setup action group for menu."""
actions = Gio.SimpleActionGroup.new() actions = Gio.SimpleActionGroup.new()
self._move_up_action = Gio.SimpleAction.new("move-up", None)
self._move_up_action.connect("activate", lambda *_: self.emit('move-up-requested'))
actions.add_action(self._move_up_action)
self._move_down_action = Gio.SimpleAction.new("move-down", None)
self._move_down_action.connect("activate", lambda *_: self.emit('move-down-requested'))
actions.add_action(self._move_down_action)
action = Gio.SimpleAction.new("manage-environments", None) action = Gio.SimpleAction.new("manage-environments", None)
action.connect("activate", lambda *_: self.emit('manage-environments-requested')) action.connect("activate", lambda *_: self.emit('manage-environments-requested'))
actions.add_action(action) actions.add_action(action)
@ -96,25 +82,16 @@ class ProjectItem(Gtk.Box):
self.name_label.set_text(self.project.name) self.name_label.set_text(self.project.name)
self.project_icon.set_from_icon_name(self.project.icon) self.project_icon.set_from_icon_name(self.project.icon)
# Clear and add requests
while child := self.requests_listbox.get_first_child(): while child := self.requests_listbox.get_first_child():
self.requests_listbox.remove(child) self.requests_listbox.remove(child)
total = len(self.project.requests) for saved_request in self.project.requests:
for i, saved_request in enumerate(self.project.requests):
item = RequestItem(saved_request) item = RequestItem(saved_request)
item.set_can_move_up(i > 0)
item.set_can_move_down(i < total - 1)
item.set_other_projects(self.other_projects)
item.connect('load-requested', item.connect('load-requested',
lambda w, sr=saved_request: self.emit('request-load-requested', sr)) lambda w, sr=saved_request: self.emit('request-load-requested', sr))
item.connect('delete-requested', item.connect('delete-requested',
lambda w, sr=saved_request: self.emit('request-delete-requested', sr)) lambda w, sr=saved_request: self.emit('request-delete-requested', sr))
item.connect('move-up-requested',
lambda w, sr=saved_request: self.emit('request-move-up-requested', sr))
item.connect('move-down-requested',
lambda w, sr=saved_request: self.emit('request-move-down-requested', sr))
item.connect('move-to-project-requested',
lambda w, pid, sr=saved_request: self.emit('request-move-to-project-requested', sr, pid))
self.requests_listbox.append(item) self.requests_listbox.append(item)
def _on_header_clicked(self, gesture, n_press, x, y): def _on_header_clicked(self, gesture, n_press, x, y):
@ -122,14 +99,6 @@ class ProjectItem(Gtk.Box):
self.expanded = not self.expanded self.expanded = not self.expanded
self.requests_revealer.set_reveal_child(self.expanded) self.requests_revealer.set_reveal_child(self.expanded)
def set_can_move_up(self, can: bool): def refresh(self):
self._move_up_action.set_enabled(can) """Refresh from project data."""
def set_can_move_down(self, can: bool):
self._move_down_action.set_enabled(can)
def refresh(self, other_projects=None):
"""Refresh from project data, optionally updating the other_projects list."""
if other_projects is not None:
self.other_projects = other_projects
self._populate() self._populate()

View File

@ -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>
@ -27,12 +28,12 @@
</child> </child>
<child> <child>
<object class="GtkMenuButton" id="menu_button"> <object class="GtkButton" id="delete_button">
<property name="icon-name">view-more-symbolic</property> <property name="icon-name">edit-delete-symbolic</property>
<property name="tooltip-text">Options</property> <property name="tooltip-text">Delete request</property>
<signal name="clicked" handler="on_delete_clicked"/>
<style> <style>
<class name="flat"/> <class name="flat"/>
<class name="request-menu-button"/>
</style> </style>
</object> </object>
</child> </child>

View File

@ -18,15 +18,7 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from gi.repository import Gtk, GObject, Gio, GLib 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')
@ -37,115 +29,28 @@ class RequestItem(Gtk.Box):
method_label = Gtk.Template.Child() method_label = Gtk.Template.Child()
name_label = Gtk.Template.Child() name_label = Gtk.Template.Child()
menu_button = Gtk.Template.Child()
__gsignals__ = { __gsignals__ = {
'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'move-to-project-requested': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_STRING,)),
} }
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.name_label.set_text(saved_request.name)
self.method_label.add_css_class(_METHOD_CSS.get(method, 'dim-label'))
display_name = saved_request.name
for prefix in _METHOD_CSS:
if display_name.upper().startswith(prefix + ' '):
display_name = display_name[len(prefix) + 1:]
break
self.name_label.set_text(display_name)
self._setup_menu() # Add click gesture for loading
# Click gesture for loading
gesture = Gtk.GestureClick.new() gesture = Gtk.GestureClick.new()
gesture.connect('released', self._on_clicked) gesture.connect('released', self._on_clicked)
self.add_controller(gesture) self.add_controller(gesture)
# Show/hide menu button on hover
motion = Gtk.EventControllerMotion.new()
motion.connect('enter', self._on_hover_enter)
motion.connect('leave', self._on_hover_leave)
self.add_controller(motion)
def _setup_menu(self):
actions = Gio.SimpleActionGroup.new()
self._move_up_action = Gio.SimpleAction.new("move-up", None)
self._move_up_action.connect("activate", lambda *_: self.emit('move-up-requested'))
actions.add_action(self._move_up_action)
self._move_down_action = Gio.SimpleAction.new("move-down", None)
self._move_down_action.connect("activate", lambda *_: self.emit('move-down-requested'))
actions.add_action(self._move_down_action)
move_to = Gio.SimpleAction.new("move-to-project", GLib.VariantType.new("s"))
move_to.connect("activate", self._on_move_to_project_activated)
actions.add_action(move_to)
delete = Gio.SimpleAction.new("delete", None)
delete.connect("activate", lambda *_: self.emit('delete-requested'))
actions.add_action(delete)
self.insert_action_group("request", actions)
# Build menu model
menu = Gio.Menu()
reorder_section = Gio.Menu()
reorder_section.append("Move Up", "request.move-up")
reorder_section.append("Move Down", "request.move-down")
menu.append_section(None, reorder_section)
self._move_to_submenu = Gio.Menu()
move_section = Gio.Menu()
move_section.append_submenu("Move to Project", self._move_to_submenu)
menu.append_section(None, move_section)
delete_section = Gio.Menu()
delete_section.append("Delete", "request.delete")
menu.append_section(None, delete_section)
self.menu_button.set_menu_model(menu)
# Hidden by default; shown on hover via EventControllerMotion.
# When the popover closes, hide the button again.
self.menu_button.set_opacity(0.0)
popover = self.menu_button.get_popover()
if popover:
popover.connect('closed', lambda p: self.menu_button.set_opacity(0.0))
def _on_move_to_project_activated(self, action, param):
self.emit('move-to-project-requested', param.get_string())
def _on_clicked(self, gesture, n_press, x, y): def _on_clicked(self, gesture, n_press, x, y):
"""Load request on row click (but not if the menu button was clicked).""" """Handle click to load request."""
alloc = self.menu_button.get_allocation()
if alloc.x <= x <= alloc.x + alloc.width and alloc.y <= y <= alloc.y + alloc.height:
return
self.emit('load-requested') self.emit('load-requested')
def _on_hover_enter(self, controller, x, y): @Gtk.Template.Callback()
self.menu_button.set_opacity(1.0) def on_delete_clicked(self, button):
"""Handle delete button click."""
def _on_hover_leave(self, controller): self.emit('delete-requested')
popover = self.menu_button.get_popover()
if not (popover and popover.get_visible()):
self.menu_button.set_opacity(0.0)
def set_can_move_up(self, can: bool):
self._move_up_action.set_enabled(can)
def set_can_move_down(self, can: bool):
self._move_down_action.set_enabled(can)
def set_other_projects(self, projects):
"""Rebuild the 'Move to Project' submenu from the given project list."""
self._move_to_submenu.remove_all()
for p in projects:
self._move_to_submenu.append(p.name, f"request.move-to-project::{p.id}")

View File

@ -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
@ -37,8 +37,6 @@ from .tab_manager import TabManager
from .icon_picker_dialog import IconPickerDialog 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 .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 +61,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_wsdl_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 +100,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 +111,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 +159,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 +201,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 +380,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,22 +494,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.connect("activate", lambda a, p: self.on_import_openapi_clicked(None))
self.add_action(action)
action = Gio.SimpleAction.new("import-wsdl", None)
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): 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
@ -952,7 +898,7 @@ class RosterWindow(Adw.ApplicationWindow):
return False, f"Request name is too long (max {REQUEST_NAME_MAX_LENGTH} characters)" return False, f"Request name is too long (max {REQUEST_NAME_MAX_LENGTH} characters)"
# Check for invalid characters (file system unsafe characters) # Check for invalid characters (file system unsafe characters)
invalid_chars = ['\\', '*', '?', '"', '<', '>', '|', '\0'] invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0']
for char in invalid_chars: for char in invalid_chars:
if char in name: if char in name:
return False, f"Request name cannot contain '{char}'" return False, f"Request name cannot contain '{char}'"
@ -961,47 +907,23 @@ class RosterWindow(Adw.ApplicationWindow):
def _load_projects(self) -> None: def _load_projects(self) -> None:
"""Load and display projects.""" """Load and display projects."""
# Remember which projects are currently expanded.
# GtkListBox wraps each child in a GtkListBoxRow, so we call get_child()
# on the row to reach the actual ProjectItem widget.
expanded_ids = set()
row = self.projects_listbox.get_first_child()
while row:
project_item = row.get_child()
if isinstance(project_item, ProjectItem) and project_item.expanded:
expanded_ids.add(project_item.project.id)
row = row.get_next_sibling()
# Clear existing # Clear existing
while child := self.projects_listbox.get_first_child(): while child := self.projects_listbox.get_first_child():
self.projects_listbox.remove(child) self.projects_listbox.remove(child)
# Load and populate # Load and populate
projects = self.project_manager.load_projects() projects = self.project_manager.load_projects()
total = len(projects) for project in projects:
for i, project in enumerate(projects): item = ProjectItem(project)
other_projects = [p for p in projects if p.id != project.id]
item = ProjectItem(project, other_projects)
item.set_can_move_up(i > 0)
item.set_can_move_down(i < total - 1)
item.connect('edit-requested', self._on_project_edit, project) item.connect('edit-requested', self._on_project_edit, project)
item.connect('delete-requested', self._on_project_delete, project) item.connect('delete-requested', self._on_project_delete, project)
item.connect('add-request-requested', self._on_add_to_project, project) item.connect('add-request-requested', self._on_add_to_project, project)
item.connect('request-load-requested', self._on_load_request) item.connect('request-load-requested', self._on_load_request)
item.connect('request-delete-requested', self._on_delete_request, project) item.connect('request-delete-requested', self._on_delete_request, project)
item.connect('manage-environments-requested', self._on_manage_environments, project) item.connect('manage-environments-requested', self._on_manage_environments, project)
item.connect('move-up-requested', self._on_project_move_up, project)
item.connect('move-down-requested', self._on_project_move_down, project)
item.connect('request-move-up-requested', self._on_request_move_up, project)
item.connect('request-move-down-requested', self._on_request_move_down, project)
item.connect('request-move-to-project-requested', self._on_request_move_to_project, project)
if project.id in expanded_ids:
item.expanded = True
item.requests_revealer.set_transition_duration(0)
item.requests_revealer.set_reveal_child(True)
GLib.idle_add(lambda r=item.requests_revealer: (r.set_transition_duration(250), False)[1])
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()
@ -1137,33 +1059,6 @@ class RosterWindow(Adw.ApplicationWindow):
dialog.connect('environments-updated', on_environments_updated) dialog.connect('environments-updated', on_environments_updated)
dialog.present(self) dialog.present(self)
def _on_project_move_up(self, widget, project):
self.project_manager.reorder_project(project.id, -1)
self._load_projects()
def _on_project_move_down(self, widget, project):
self.project_manager.reorder_project(project.id, +1)
self._load_projects()
def _on_request_move_up(self, widget, saved_request, project):
self.project_manager.reorder_request(project.id, saved_request.id, -1)
self._load_projects()
def _on_request_move_down(self, widget, saved_request, project):
self.project_manager.reorder_request(project.id, saved_request.id, +1)
self._load_projects()
def _on_request_move_to_project(self, widget, saved_request, target_project_id, source_project):
self.project_manager.move_request_to_project(
saved_request.id, source_project.id, target_project_id
)
self._load_projects()
# Find target project name for toast
projects = self.project_manager.load_projects()
target = next((p for p in projects if p.id == target_project_id), None)
if target:
self._show_toast(f"Moved to '{target.name}'")
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_save_request_clicked(self, button): def on_save_request_clicked(self, button):
"""Save current request to a project.""" """Save current request to a project."""
@ -1518,22 +1413,7 @@ 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): @Gtk.Template.Callback()
"""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()
@ -1541,22 +1421,6 @@ class RosterWindow(Adw.ApplicationWindow):
dialog.connect('import-completed', self._on_wsdl_import_completed) dialog.connect('import-completed', self._on_wsdl_import_completed)
dialog.present(self) dialog.present(self)
def on_import_openapi_clicked(self, button):
"""Open the OpenAPI import dialog."""
projects = self.project_manager.load_projects()
dialog = OpenApiImportDialog(self.project_manager, projects)
dialog.connect('import-completed', self._on_openapi_import_completed)
dialog.present(self)
def _on_openapi_import_completed(self, dialog, project_id: str):
"""Refresh sidebar after a successful OpenAPI 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)} operation(s) into '{p.name}'")
break
def _on_wsdl_import_completed(self, dialog, project_id: str): def _on_wsdl_import_completed(self, dialog, project_id: str):
"""Refresh sidebar after a successful WSDL import.""" """Refresh sidebar after a successful WSDL import."""
self._load_projects() self._load_projects()

View File

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