Compare commits

..

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

65 changed files with 685 additions and 3546 deletions

View File

@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Roster is a GNOME application written in Python using GTK 4 and libadwaita. The project follows GNOME application conventions and uses the Meson build system. Roster is a GNOME application written in Python using GTK 4 and libadwaita. The project follows GNOME application conventions and uses the Meson build system.
- **Application ID**: `cz.bugsy.roster` - **Application ID**: `cz.vesp.roster`
- **Build System**: Meson - **Build System**: Meson
- **Runtime**: GNOME Platform (org.gnome.Platform) - **Runtime**: GNOME Platform (org.gnome.Platform)
- **UI Framework**: GTK 4 + libadwaita (Adw) - **UI Framework**: GTK 4 + libadwaita (Adw)
@ -40,12 +40,12 @@ The application uses **libsoup3** (from GNOME Platform) for HTTP requests - no e
## Development with Flatpak ## Development with Flatpak
The project includes a Flatpak manifest (`cz.bugsy.roster.json`) for building and running the application in a sandboxed environment: The project includes a Flatpak manifest (`cz.vesp.roster.json`) for building and running the application in a sandboxed environment:
```bash ```bash
# Build with GNOME Builder (recommended for GNOME apps) # Build with GNOME Builder (recommended for GNOME apps)
# Or use flatpak-builder directly: # Or use flatpak-builder directly:
flatpak-builder --user --install --force-clean build-dir cz.bugsy.roster.json flatpak-builder --user --install --force-clean build-dir cz.vesp.roster.json
``` ```
## Architecture ## Architecture
@ -83,10 +83,10 @@ src/
roster.gresource.xml # GResource bundle definition roster.gresource.xml # GResource bundle definition
data/ data/
cz.bugsy.roster.desktop.in # Desktop entry (i18n template) cz.vesp.roster.desktop.in # Desktop entry (i18n template)
cz.bugsy.roster.metainfo.xml.in # AppStream metadata (i18n template) cz.vesp.roster.metainfo.xml.in # AppStream metadata (i18n template)
cz.bugsy.roster.gschema.xml # GSettings schema cz.vesp.roster.gschema.xml # GSettings schema
cz.bugsy.roster.service.in # D-Bus service file (configured by Meson) cz.vesp.roster.service.in # D-Bus service file (configured by Meson)
icons/ # Application icons icons/ # Application icons
po/ po/
@ -116,10 +116,10 @@ When adding new `.py` files to `src/`:
When adding new `.ui` files: When adding new `.ui` files:
1. Place in `src/` directory 1. Place in `src/` directory
2. Add `<file>` entry to `src/roster.gresource.xml` 2. Add `<file>` entry to `src/roster.gresource.xml`
3. Use `@Gtk.Template(resource_path='/cz/bugsy/roster/filename.ui')` in the corresponding Python class 3. Use `@Gtk.Template(resource_path='/cz/vesp/roster/filename.ui')` in the corresponding Python class
## GSettings Schema ## GSettings Schema
Settings are defined in `data/cz.bugsy.roster.gschema.xml`. After modifying: Settings are defined in `data/cz.vesp.roster.gschema.xml`. After modifying:
- Schema is validated during `meson test` - Schema is validated during `meson test`
- Compiled automatically during installation via `gnome.post_install()` - Compiled automatically during installation via `gnome.post_install()`

View File

@ -1,32 +0,0 @@
Application Icons License
=========================
The application icons in this repository are licensed under the Creative Commons
Attribution-ShareAlike 3.0 International License (CC BY-SA 3.0).
Copyright (c) 2025 Pavel Baksy
You are free to:
- Share — copy and redistribute the material in any medium or format
- Adapt — remix, transform, and build upon the material for any purpose,
even commercially
Under the following terms:
- Attribution — You must give appropriate credit, provide a link to the
license, and indicate if changes were made. You may do so in any reasonable
manner, but not in any way that suggests the licensor endorses you or your use.
- ShareAlike — If you remix, transform, or build upon the material, you must
distribute your contributions under the same license as the original.
- No additional restrictions — You may not apply legal terms or technological
measures that legally restrict others from doing anything the license permits.
Icons covered by this license:
- data/icons/hicolor/scalable/apps/cz.bugsy.roster.svg
- data/icons/hicolor/symbolic/apps/cz.bugsy.roster-symbolic.svg
To view the full license, visit:
https://creativecommons.org/licenses/by-sa/3.0/legalcode
SPDX-License-Identifier: CC-BY-SA-3.0

View File

@ -1,20 +0,0 @@
# Third-Party Dependencies
This document is provided for informational purposes and does not replace the original licenses.
Roster uses GNOME platform libraries and Python, all with GPL-3.0-or-later compatible licenses.
## GNOME Platform Libraries (LGPL-2.1-or-later)
- GTK 4, libadwaita, libsoup 3, GtkSourceView 5, GLib
These are dynamically linked system libraries available on https://gitlab.gnome.org/GNOME/
## Python (PSF-2.0)
- Python 3 standard library
## GNOME Icons (LGPL-3.0-or-later and CC-BY-SA-3.0)
- This application uses icons from the GNOME Project (Adwaita icon theme)
Icons are loaded at runtime from the system and are not distributed with this application.
All licenses are compatible with GPL-3.0-or-later.

110
README.md
View File

@ -6,115 +6,27 @@ A modern HTTP client for GNOME, built with GTK 4 and libadwaita.
- Send HTTP requests (GET, POST, PUT, DELETE) - Send HTTP requests (GET, POST, PUT, DELETE)
- Configure custom headers and request bodies - Configure custom headers and request bodies
- View response headers and bodies with syntax highlighting - View response headers and bodies
- Track request history with persistence - Track request history with persistence
- Organize requests into projects - Beautiful GNOME-native UI
- Environment variables with secure credential storage
- JavaScript preprocessing and postprocessing scripts
- Export requests (cURL and more)
- GNOME-native UI
## Screenshots ## Dependencies
### Main Interface - GTK 4
![Main Interface](screenshots/main.png) - libadwaita 1
- Python 3
- libsoup3 (provided by GNOME Platform)
### Request History ## Building
![Request History](screenshots/history.png)
### Environment Variables
![Environment Variables](screenshots/variables.png)
## Quick Start
### Installation
**Build from source:**
```bash ```bash
git clone https://git.bugsy.cz/beval/roster.git
cd roster
meson setup builddir meson setup builddir
meson compile -C builddir meson compile -C builddir
sudo meson install -C builddir sudo meson install -C builddir
``` ```
**Flatpak:** ## Usage
```bash
flatpak-builder --user --install --force-clean build-dir cz.bugsy.roster.json
flatpak run cz.bugsy.roster
```
See the [Installation Guide](https://git.bugsy.cz/beval/roster/wiki/Installation) for detailed instructions. Roster uses libsoup3 (from GNOME Platform) for making HTTP requests - no external dependencies required.
### Usage Run Roster from your application menu or with the `roster` command.
Launch Roster from your application menu or run `roster` from the command line.
See the [Getting Started Guide](https://git.bugsy.cz/beval/roster/wiki/Getting-Started) for a walkthrough.
Complete documentation is available in the [Wiki](https://git.bugsy.cz/beval/roster/wiki):
## Key Features
### Multi-Environment Support
Manage multiple environments (development, staging, production) with different variable values. Switch environments with one click.
[Learn more about Variables](https://git.bugsy.cz/beval/roster/wiki/Variables)
### Secure Credential Storage
Store API keys, passwords, and tokens securely in GNOME Keyring with one-click encryption. Regular variables are stored in JSON, while sensitive variables are encrypted.
[Learn more about Sensitive Variables](https://git.bugsy.cz/beval/roster/wiki/Sensitive-Variables)
### JavaScript Automation
Use preprocessing and postprocessing scripts to:
- Extract authentication tokens from responses
- Add dynamic headers (timestamps, signatures)
- Chain requests together
- Validate responses
[Learn more about Scripts](https://git.bugsy.cz/bavel/roster/wiki/Scripts)
## Dependencies
### Runtime
- GTK 4
- libadwaita 1
- Python 3
- libsoup3 (provided by GNOME Platform)
- libsecret (provided by GNOME Platform)
- GJS (GNOME JavaScript)
### Build
- Meson (>= 1.0.0)
- Ninja
- pkg-config
- gettext
See the [Installation Guide](https://git.bugsy.cz/beval/roster/wiki/Installation) for platform-specific dependencies.
## Platform
Roster is built specifically for the GNOME desktop using native technologies:
- **GTK 4** - Modern UI toolkit
- **libadwaita** - GNOME design patterns
- **libsoup3** - HTTP networking
- **libsecret** - Secure credential storage
- **GJS** - JavaScript runtime for scripts
## License
GPL-3.0-or-later
## Support
- **Issues**: [git.bugsy.cz/beval/roster/issues](https://git.bugsy.cz/beval/roster/issues)
- **Wiki**: [git.bugsy.cz/beval/roster/wiki](https://git.bugsy.cz/bavel/roster/wiki)
- **Source**: [git.bugsy.cz/beval/roster](https://git.bugsy.cz/beval/roster)
## Contributing
Contributions are welcome! Please open an issue or pull request.

View File

@ -1,39 +0,0 @@
{
"id": "cz.bugsy.roster",
"runtime": "org.gnome.Platform",
"runtime-version": "49",
"sdk": "org.gnome.Sdk",
"command": "roster",
"finish-args": [
"--share=network",
"--share=ipc",
"--socket=fallback-x11",
"--device=dri",
"--socket=wayland"
],
"cleanup": [
"/include",
"/lib/pkgconfig",
"/man",
"/share/doc",
"/share/gtk-doc",
"/share/man",
"/share/pkgconfig",
"*.la",
"*.a"
],
"modules": [
{
"name": "roster",
"builddir": true,
"buildsystem": "meson",
"sources": [
{
"type": "git",
"url": "https://git.bugsy.cz/beval/roster.git",
"tag": "v0.8.2"
}
]
}
]
}

View File

@ -1,5 +1,5 @@
{ {
"id" : "cz.bugsy.roster", "id" : "cz.vesp.roster",
"runtime" : "org.gnome.Platform", "runtime" : "org.gnome.Platform",
"runtime-version" : "49", "runtime-version" : "49",
"sdk" : "org.gnome.Sdk", "sdk" : "org.gnome.Sdk",

View File

@ -1,11 +0,0 @@
[Desktop Entry]
Name=Roster
Comment=HTTP client for API testing
Exec=roster
Icon=cz.bugsy.roster
Terminal=false
Type=Application
Categories=Development;Network;
Keywords=HTTP;REST;API;Client;Request;JSON;
StartupNotify=true
DBusActivatable=true

View File

@ -1,115 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>cz.bugsy.roster</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<name>Roster</name>
<summary>HTTP client for API testing</summary>
<description>
<p>Roster is a modern HTTP client for testing and debugging REST APIs. It provides a clean, GNOME-native interface for making HTTP requests and inspecting responses.</p>
</description>
<developer id="cz.bugsy">
<name>Pavel Baksy</name>
</developer>
<url type="homepage">https://git.bugsy.cz/beval/roster</url>
<url type="vcs-browser">https://git.bugsy.cz/beval/roster</url>
<url type="bugtracker">https://git.bugsy.cz/beval/roster/issues</url>
<translation type="gettext">roster</translation>
<!-- All graphical applications having a desktop file must have this tag in the MetaInfo.
If this is present, appstreamcli compose will pull icons, keywords and categories from the desktop file. -->
<launchable type="desktop-id">cz.bugsy.roster.desktop</launchable>
<!-- Use the OARS website (https://hughsie.github.io/oars/generate.html) to generate these and make sure to use oars-1.1 -->
<content_rating type="oars-1.1" />
<branding>
<color type="primary" scheme_preference="light">#8b9dbc</color>
<color type="primary" scheme_preference="dark">#173c7a</color>
</branding>
<screenshots>
<screenshot type="default">
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/main.png</image>
<caption>Main application window with HTTP request and response</caption>
</screenshot>
<screenshot>
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/variables.png</image>
<caption>Environment variables configuration</caption>
</screenshot>
<screenshot>
<image>https://git.bugsy.cz/beval/roster/raw/branch/master/screenshots/history.png</image>
<caption>Request history panel</caption>
</screenshot>
</screenshots>
<releases>
<release version="0.8.1" date="2026-01-15">
<description translate="no">
<p>Version 0.8.1 release</p>
<ul>
<li>Remove license note from app description</li>
</ul>
</description>
</release>
<release version="0.8.0" date="2026-01-15">
<description translate="no">
<p>Version 0.8.0 release</p>
<ul>
<li>Shorten User-Agent version to major.minor format</li>
</ul>
</description>
</release>
<release version="0.7.0" date="2026-01-14">
<description translate="no">
<p>Version 0.7.0 release</p>
<ul>
<li>Prepare for Flathub submission</li>
<li>Add real screenshots and release notes</li>
<li>Add Request creates new tab for project</li>
</ul>
</description>
</release>
<release version="0.6.0" date="2026-01-10">
<description translate="no">
<p>Version 0.6.0 release</p>
<ul>
<li>Added keyboard shortcuts: Ctrl+L (focus URL), Ctrl+Return (send request)</li>
<li>Translation improvements</li>
</ul>
</description>
</release>
<release version="0.5.0" date="2026-01-08">
<description translate="no">
<p>Version 0.5.0 release</p>
<ul>
<li>Authentication improvements</li>
<li>Bug fixes and stability improvements</li>
</ul>
</description>
</release>
<release version="0.4.0" date="2026-01-05">
<description translate="no">
<p>Version 0.4.0 release</p>
<ul>
<li>Improved sidebar layout with reduced indentation</li>
<li>Enhanced panel resizing capabilities</li>
<li>Better project folder state management</li>
<li>Status icons fixes in panels</li>
</ul>
</description>
</release>
<release version="0.2.0" date="2025-12-30">
<description translate="no">
<p>Version 0.2.0 release</p>
<ul>
<li>Environment variable support improvements</li>
<li>UI refinements and bug fixes</li>
</ul>
</description>
</release>
</releases>
</component>

View File

@ -0,0 +1,10 @@
[Desktop Entry]
Name=roster
Exec=roster
Icon=cz.vesp.roster
Terminal=false
Type=Application
Categories=Utility;
Keywords=GTK;
StartupNotify=true
DBusActivatable=true

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<schemalist gettext-domain="roster"> <schemalist gettext-domain="roster">
<schema id="cz.bugsy.roster" path="/cz/bugsy/roster/"> <schema id="cz.vesp.roster" path="/cz/vesp/roster/">
<key name="force-tls-verification" type="b"> <key name="force-tls-verification" type="b">
<default>true</default> <default>true</default>
<summary>Force TLS verification</summary> <summary>Force TLS verification</summary>

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>cz.vesp.roster</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<name>Roster</name>
<summary>Keep the summary shorter, between 10 and 35 characters</summary>
<description>
<p>No description</p>
</description>
<developer id="tld.vendor">
<name>Developer name</name>
</developer>
<!-- Required: Should be a link to the upstream homepage for the component -->
<url type="homepage">https://example.org/</url>
<!-- Recommended: It is highly recommended for open-source projects to display the source code repository -->
<url type="vcs-browser">https://example.org/repository</url>
<!-- Should point to the software's bug tracking system, for users to report new bugs -->
<url type="bugtracker">https://example.org/issues</url>
<!-- Should link a FAQ page for this software, to answer some of the most-asked questions in detail -->
<!-- URLs of this type should point to a webpage where users can submit or modify translations of the upstream project -->
<url type="translate">https://example.org/translate</url>
<url type="faq">https://example.org/faq</url>
<!-- Should provide a web link to an online user's reference, a software manual or help page -->
<url type="help">https://example.org/help</url>
<!-- URLs of this type should point to a webpage showing information on how to donate to the described software project -->
<url type="donation">https://example.org/donate</url>
<!-- This could for example be an HTTPS URL to an online form or a page describing how to contact the developer -->
<url type="contact">https://example.org/contact</url>
<!-- URLs of this type should point to a webpage showing information on how to contribute to the described software project -->
<url type="contribute">https://example.org/contribute</url>
<translation type="gettext">roster</translation>
<!-- All graphical applications having a desktop file must have this tag in the MetaInfo.
If this is present, appstreamcli compose will pull icons, keywords and categories from the desktop file. -->
<launchable type="desktop-id">cz.vesp.roster.desktop</launchable>
<!-- Use the OARS website (https://hughsie.github.io/oars/generate.html) to generate these and make sure to use oars-1.1 -->
<content_rating type="oars-1.1" />
<!-- Applications should set a brand color in both light and dark variants like so -->
<branding>
<color type="primary" scheme_preference="light">#ff00ff</color>
<color type="primary" scheme_preference="dark">#993d3d</color>
</branding>
<screenshots>
<screenshot type="default">
<image>https://example.org/example1.png</image>
<caption>A caption</caption>
</screenshot>
<screenshot>
<image>https://example.org/example2.png</image>
<caption>A caption</caption>
</screenshot>
</screenshots>
<releases>
<release version="0.2.0" date="2025-12-30">
<description translate="no">
<p>Version 0.2.0 release</p>
<ul>
<li>Environment variable support improvements</li>
<li>UI refinements and bug fixes</li>
</ul>
</description>
</release>
<release version="1.0.1" date="2024-01-18">
<url type="details">https://example.org/changelog.html#version_1.0.1</url>
<description translate="no">
<p>Release description</p>
<ul>
<li>List of changes</li>
<li>List of changes</li>
</ul>
</description>
</release>
</releases>
</component>

View File

@ -1,3 +1,3 @@
[D-BUS Service] [D-BUS Service]
Name=cz.bugsy.roster Name=cz.vesp.roster
Exec=@bindir@/roster --gapplication-service Exec=@bindir@/roster --gapplication-service

View File

@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<linearGradient id="a" gradientUnits="userSpaceOnUse" spreadMethod="reflect" x1="63.93500143502" x2="116.66087219391" y1="114.25781884399" y2="113.87787042471">
<stop offset="0" stop-color="#567cb9"/>
<stop offset="0.691254" stop-color="#567cb9" stop-opacity="0.74902"/>
<stop offset="0.738096" stop-color="#567cb9" stop-opacity="0.623529"/>
<stop offset="0.782391" stop-color="#567cb9" stop-opacity="0.498039"/>
<stop offset="0.821796" stop-color="#567cb9" stop-opacity="0.74902"/>
<stop offset="0.85385" stop-color="#567cb9" stop-opacity="0.87451"/>
<stop offset="0.955449" stop-color="#567cb9" stop-opacity="0.937255"/>
<stop offset="1" stop-color="#567cb9"/>
</linearGradient>
<linearGradient id="b" gradientTransform="matrix(0.185607 0 0 0.185596 -41.369942 -39.816223)" gradientUnits="userSpaceOnUse" spreadMethod="reflect" x1="781.887085" x2="537.229431" y1="760.514282" y2="763.320618">
<stop offset="0" stop-color="#173c7a"/>
<stop offset="0.149359" stop-color="#5383d9"/>
<stop offset="0.246092" stop-color="#1f4a92"/>
<stop offset="0.337206" stop-color="#1b4386"/>
<stop offset="1" stop-color="#173c7a"/>
</linearGradient>
<path d="m 94.542969 18.238281 c 1.398437 0.253907 1.480469 0.511719 1.480469 4.527344 c 0 2.933594 0.132812 4.207031 0.589843 5.683594 c 1 3.242187 3.492188 6.097656 6.773438 7.769531 c 0.667969 0.339844 2.316406 0.605469 5.035156 0.808594 c 5.144531 0.386718 5.445313 0.519531 5.742187 2.585937 c 0.128907 0.875 0.195313 16.273438 0.152344 34.210938 l -0.078125 32.613281 l -0.964843 1.960938 c -1.171876 2.382812 -3.964844 5.121093 -6.488282 6.359374 l -1.726562 0.847657 h -40.589844 l -40.589844 -0.136719 c -0.402344 0.003906 -2.464844 -0.859375 -2.464844 -0.859375 c -2.792968 -1.207031 -4.558593 -3.820313 -6.070312 -6.554687 l -1.074219 -1.949219 l 0.160157 -39.21875 v -39.242188 l 0.992187 -2.027343 c 1.871094 -3.824219 5.535156 -6.671876 9.390625 -7.304688 c 1.742188 -0.285156 68.199219 -0.355469 69.730469 -0.074219 z m 0 0" fill="url(#a)"/>
<path d="m 94.382812 13.8125 c 1.394532 0.257812 1.480469 0.515625 1.480469 4.527344 c 0 2.9375 0.128907 4.210937 0.585938 5.683594 c 1 3.242187 3.492187 6.101562 6.773437 7.773437 c 0.667969 0.339844 2.316406 0.605469 5.035156 0.808594 c 5.148438 0.382812 5.449219 0.519531 5.746094 2.582031 c 0.125 0.878906 0.195313 16.273438 0.152344 34.210938 l -0.078125 32.617187 l -0.96875 1.960937 c -1.171875 2.382813 -3.964844 5.121094 -6.484375 6.359376 l -1.730469 0.847656 h -40.589843 l -40.71875 0.078125 c -0.941407 -0.027344 -2.34375 -0.996094 -2.34375 -0.996094 c -2.597657 -1.457031 -4.71875 -3.8125 -6.058594 -6.632813 l -0.914063 -1.925781 v -78.484375 l 0.992188 -2.027344 c 1.867187 -3.824218 5.53125 -6.675781 9.386719 -7.304687 c 1.742187 -0.285156 68.199218 -0.359375 69.734374 -0.078125 z m 0 0" fill="#96bcf9"/>
<path d="m 12.089844 26.011719 h 79.894531 c 0.824219 0 1.492187 0.273437 1.492187 0.613281 v 51.039062 c 0 0.339844 -0.667968 0.617188 -1.492187 0.617188 h -79.894531 c -0.820313 0 -1.488282 -0.277344 -1.488282 -0.617188 v -51.039062 c 0 -0.339844 0.667969 -0.613281 1.488282 -0.613281 z m 0 0" fill="#96bcf9"/>
<path d="m 111.046875 56.371094 c 0.945313 0.621094 10.683594 7.730468 14.746094 11.3125 c 0 0 -0.011719 1.800781 -0.011719 2.089844 c 0 0.316406 -2.621094 2.820312 -6.546875 6.25 c -9.179687 8.019531 -8.914063 7.832031 -9.84375 6.804687 c -0.40625 -0.445313 -0.488281 -1.238281 -0.488281 -4.589844 v -4.046875 h -2.804688 c -2.46875 0 -2.839844 -0.070312 -3.109375 -0.574218 c -0.167969 -0.316407 -0.304687 -2.238282 -0.304687 -4.273438 c 0 -4.363281 0.164062 -4.695312 2.335937 -4.625 c 0.75 0.027344 1.929688 0.035156 2.621094 0.019531 l 1.261719 -0.023437 v -3.722656 c 0 -4.699219 0.472656 -5.71875 2.144531 -4.621094 z m 0 0" fill="#173c7a"/>
<path d="m 23.355469 103.21875 c -1.398438 -0.257812 -1.480469 -0.511719 -1.480469 -4.527344 c 0 -2.933594 -0.132812 -4.207031 -0.585938 -5.683594 c -1.003906 -3.242187 -3.492187 -6.101562 -6.777343 -7.773437 c -0.667969 -0.339844 -2.316407 -0.605469 -5.035157 -0.808594 c -5.144531 -0.382812 -5.445312 -0.519531 -5.742187 -2.582031 c -0.128906 -0.878906 -0.195313 -16.273438 -0.152344 -34.210938 l 0.078125 -32.617187 l 0.964844 -1.960937 c 1.171875 -2.382813 3.964844 -5.117188 6.488281 -6.355469 l 1.726563 -0.851563 h 81.1875 l 2.25 1.113282 c 2.730468 1.347656 5.996094 3.667968 6.835937 6.675781 l 0.703125 2.527343 l -0.382812 38.402344 s 1.335937 36.335938 0.039062 39.242188 l -0.992187 2.03125 c -1.867188 3.820312 -5.53125 6.671875 -9.386719 7.304687 c -1.746094 0.285157 -68.207031 0.355469 -69.738281 0.074219 z m 28.640625 -22.28125 c 1.199218 -0.730469 1.867187 -2.007812 1.867187 -3.566406 c 0 -1.574219 -0.628906 -2.75 -1.878906 -3.523438 c -0.984375 -0.609375 -1.296875 -0.621094 -15.808594 -0.621094 h -14.804687 l -1.003906 0.675782 c -1.257813 0.847656 -1.742188 1.789062 -1.746094 3.410156 c -0.003906 1.523438 0.570312 2.738281 1.691406 3.574219 c 0.804688 0.601562 1.117188 0.613281 15.777344 0.621093 c 14.324218 0.007813 15 -0.015624 15.90625 -0.570312 z m 24.613281 -20.390625 c 1.144531 -0.652344 1.835937 -2.109375 1.835937 -3.871094 c 0 -1.488281 -0.085937 -1.679687 -1.292968 -2.886719 l -1.292969 -1.292968 h -54.753906 l -1.242188 1.195312 c -0.824219 0.789063 -1.257812 1.46875 -1.277343 2 c -0.101563 2.636719 0.472656 3.964844 2.105468 4.855469 c 1 0.546875 1.929688 0.5625 28.136719 0.476563 c 21.710937 -0.070313 27.234375 -0.164063 27.78125 -0.476563 z m 3.613281 -21.28125 l 6.070313 -0.195313 l 0.9375 -0.929687 c 0.515625 -0.515625 1.089843 -1.5 1.277343 -2.195313 c 0.292969 -1.097656 0.261719 -1.421874 -0.269531 -2.515624 c -0.335937 -0.691407 -1.007812 -1.550782 -1.492187 -1.910157 l -0.882813 -0.652343 l -64.867187 0.15625 l -1.246094 1.246093 c -1.121094 1.117188 -1.246094 1.390625 -1.246094 2.671875 c 0 1.542969 0.664063 2.96875 1.703125 3.65625 c 0.515625 0.34375 3.328125 0.449219 15.292969 0.578125 c 24.835938 0.265625 38.390625 0.292969 44.722656 0.089844 z m 0 0" fill="url(#b)"/>
<path d="m 23.847656 99.390625 c -1.398437 -0.253906 -1.480468 -0.511719 -1.480468 -4.523437 c 0 -2.9375 -0.132813 -4.210938 -0.585938 -5.683594 c -1.003906 -3.242188 -3.492188 -6.101563 -6.777344 -7.773438 c -0.667968 -0.339844 -2.316406 -0.605468 -5.03125 -0.808594 c -5.148437 -0.382812 -5.449218 -0.519531 -5.746094 -2.585937 c -0.125 -0.875 -0.195312 -16.269531 -0.152343 -34.210937 l 0.078125 -32.613282 l 0.964844 -1.960937 c 1.175781 -2.382813 3.96875 -5.121094 6.488281 -6.359375 l 1.726562 -0.847656 h 81.179688 l 2.253906 1.113281 c 2.730469 1.347656 4.941406 3.613281 6.28125 6.4375 l 0.914063 1.925781 v 39.242188 s 0.109374 38.625 0 39.242187 l -0.992188 2.027344 c -1.871094 3.824219 -5.53125 6.671875 -9.390625 7.304687 c -1.742187 0.285156 -68.199219 0.359375 -69.730469 0.074219 z m 28.640625 -22.277344 c 1.195313 -0.730469 1.863281 -2.007812 1.863281 -3.566406 c 0 -1.574219 -0.625 -2.753906 -1.878906 -3.527344 c -0.984375 -0.605469 -1.292968 -0.621093 -15.808594 -0.621093 h -14.800781 l -1.003906 0.675781 c -1.257813 0.851562 -1.742187 1.789062 -1.746094 3.414062 c -0.003906 1.519531 0.570313 2.738281 1.691407 3.570313 c 0.804687 0.601562 1.117187 0.617187 15.777343 0.625 c 14.320313 0.003906 14.996094 -0.019532 15.90625 -0.570313 z m 24.609375 -20.394531 c 1.144532 -0.648438 1.835938 -2.105469 1.835938 -3.867188 c 0 -1.488281 -0.085938 -1.679687 -1.292969 -2.890624 l -1.292969 -1.292969 h -54.75 l -1.246094 1.195312 c -0.820312 0.792969 -1.253906 1.46875 -1.273437 2 c -0.101563 2.636719 0.472656 3.964844 2.105469 4.855469 c 1 0.546875 1.929687 0.5625 28.132812 0.476562 c 21.710938 -0.070312 27.234375 -0.164062 27.78125 -0.476562 z m 3.613282 -21.277344 l 6.070312 -0.195312 l 0.9375 -0.933594 c 0.515625 -0.511719 1.089844 -1.5 1.277344 -2.191406 c 0.292968 -1.101563 0.261718 -1.421875 -0.269532 -2.519532 c -0.335937 -0.691406 -1.007812 -1.550781 -1.492187 -1.90625 l -0.882813 -0.65625 l -32.433593 0.078126 l -32.429688 0.078124 l -1.246093 1.246094 c -1.121094 1.121094 -1.246094 1.394532 -1.246094 2.671875 c 0 1.542969 0.664062 2.96875 1.703125 3.660157 c 0.515625 0.34375 3.328125 0.449218 15.292969 0.578124 c 24.832031 0.265626 38.386718 0.292969 44.71875 0.089844 z m 0 0" fill="#2e7af4"/>
<path d="m 111.0625 54.285156 c 0.945312 0.617188 10.394531 8.785156 12.878906 11.125 c 1.019532 0.964844 1.855469 1.988282 1.855469 2.277344 c 0 0.3125 -2.625 2.816406 -6.546875 6.246094 c -9.179688 8.019531 -8.914062 7.835937 -9.84375 6.808594 c -0.40625 -0.449219 -0.492188 -1.238282 -0.492188 -4.589844 v -4.050782 h -2.800781 c -2.46875 0 -2.839843 -0.066406 -3.109375 -0.570312 c -0.167968 -0.316406 -0.308594 -2.238281 -0.308594 -4.273438 c 0 -4.367187 0.167969 -4.699218 2.335938 -4.625 c 0.753906 0.023438 1.933594 0.03125 2.625 0.019532 l 1.257812 -0.027344 v -3.722656 c 0 -4.699219 0.476563 -5.71875 2.148438 -4.617188 z m 0 0" fill="#2e7af4"/>
<path d="m 98.59375 70.117188 c 0.375 0.375 0.464844 1.210937 0.464844 4.296874 v 3.828126 h 2.753906 c 1.75 0 1.835938 0.121093 1.855469 0.4375 c 0.046875 0.773437 0.410156 7.398437 0.332031 8.183593 c -0.015625 0.160157 -1.058594 0.332031 -2.1875 0.484375 l -2.753906 0.371094 v 3.964844 c 0 3.441406 -0.074219 4.058594 -0.554688 4.65625 c -0.746094 0.917968 -1.207031 0.683594 -4.816406 -2.445313 c -1.566406 -1.359375 -4.605469 -3.992187 -6.75 -5.847656 c -4.023438 -3.484375 -4.914062 -4.570313 -4.4375 -5.417969 c 0.25 -0.445312 10.828125 -9.90625 13.441406 -12.023437 c 1.3125 -1.058594 1.960938 -1.179688 2.652344 -0.488281 z m 0 0" fill="#173c7a"/>
<path d="m 98.734375 68.582031 c 0.375 0.375 0.464844 1.207031 0.464844 4.292969 v 3.832031 h 2.757812 c 1.746094 0 3.996094 0.128907 4.222657 0.351563 c 0.492187 0.496094 0.492187 8.273437 0 8.769531 c -0.226563 0.222656 -2.476563 0.355469 -4.222657 0.355469 h -2.757812 v 3.964844 c 0 3.441406 -0.070313 4.054687 -0.554688 4.652343 c -0.746093 0.917969 -1.203125 0.6875 -4.8125 -2.441406 c -1.570312 -1.363281 -4.609375 -3.992187 -6.753906 -5.851563 c -4.023437 -3.480468 -4.914063 -4.566406 -4.4375 -5.414062 c 0.25 -0.449219 10.828125 -9.910156 13.445313 -12.023438 c 1.308593 -1.0625 1.957031 -1.179687 2.648437 -0.488281 z m 0 0" fill="#96bcf9"/>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,130 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
width="33.750061mm"
height="33.750061mm"
viewBox="0 0 33.750061 33.750061"
version="1.1"
id="svg974">
<defs
id="defs968">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath18689">
<rect
clip-path="none"
transform="rotate(45)"
ry="32.000008"
rx="32.000008"
y="123.9986"
x="486.03726"
height="362.94299"
width="362.94299"
id="rect18691"
style="display:inline;opacity:1;vector-effect:none;fill:#4a86cf;fill-opacity:1;stroke:none;stroke-width:26.0669;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath18689-3">
<rect
clip-path="none"
transform="rotate(45)"
ry="32.000008"
rx="32.000008"
y="123.9986"
x="486.03726"
height="362.94299"
width="362.94299"
id="rect18691-6"
style="display:inline;opacity:1;vector-effect:none;fill:#4a86cf;fill-opacity:1;stroke:none;stroke-width:26.0669;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
</clipPath>
</defs>
<metadata
id="metadata971">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
transform="translate(-61.819823,-103.94395)">
<g
transform="matrix(0.26367235,0,0,0.26367235,61.819823,-529.39703)"
style="display:inline;stroke-width:0.25;enable-background:new"
id="g1836">
<title
id="title1838">application-x-executable</title>
<g
transform="matrix(0.25,0,0,0.25,0,2295)"
id="g18818"
style="stroke-width:0.25">
<g
style="stroke-width:0.269963"
transform="matrix(0.92605186,0,0,0.92605186,18.930729,50.876335)"
id="g18590">
<g
style="stroke-width:0.269963"
id="g18681"
clip-path="url(#clipPath18689-3)">
<rect
style="opacity:1;vector-effect:none;fill:#3584e4;fill-opacity:1;stroke:none;stroke-width:8.22095;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
id="rect18571"
width="424"
height="424"
x="458.33722"
y="90.641701"
rx="10.092117"
ry="10.092117"
transform="matrix(0.60528171,0.60528171,-0.60528171,0.60528171,33.440632,99.073632)"
clip-path="none" />
<circle
style="opacity:1;vector-effect:none;fill:#f66151;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
id="path18706"
cx="0"
cy="0"
r="0"
transform="translate(0,-212)" />
<circle
style="opacity:1;vector-effect:none;fill:#f66151;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal"
id="path18708"
cx="0"
cy="0"
r="0"
transform="translate(0,-212)" />
<path
style="display:inline;opacity:1;vector-effect:none;fill:#98c1f1;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
d="m 408.91993,561.9183 -9.8861,29.82892 a 172.97099,172.97099 0 0 0 -1.42693,-0.0713 172.97099,172.97099 0 0 0 -23.92891,1.85189 l -13.80082,-28.50125 a 203.29325,203.29325 0 0 0 -29.40085,7.97217 l 2.28619,31.40474 a 172.97099,172.97099 0 0 0 -22.73152,11.31923 l -23.71796,-21.103 a 203.29325,203.29325 0 0 0 -24.05918,18.6741 l 14.09863,28.07319 a 172.97099,172.97099 0 0 0 -16.63608,19.21074 l -30.05845,-10.44758 a 203.29325,203.29325 0 0 0 -15.01683,26.48807 l 23.73035,20.50738 a 172.97099,172.97099 0 0 0 -7.98456,24.14293 l -31.73044,1.84879 a 203.29325,203.29325 0 0 0 -3.77825,30.21664 l 29.82892,9.8861 a 172.97099,172.97099 0 0 0 -0.0713,1.42693 172.97099,172.97099 0 0 0 1.85188,23.92889 l -28.50125,13.80084 a 203.29325,203.29325 0 0 0 7.97215,29.40084 l 31.40475,-2.28619 a 172.97099,172.97099 0 0 0 11.31922,22.73152 l -21.10296,23.71797 a 203.29325,203.29325 0 0 0 18.67409,24.05918 l 28.07319,-14.09863 a 172.97099,172.97099 0 0 0 19.21074,16.63606 l -10.44758,30.05847 a 203.29325,203.29325 0 0 0 26.48806,15.01683 l 20.50739,-23.73036 a 172.97099,172.97099 0 0 0 24.14293,7.98457 l 1.8488,31.73043 a 203.29325,203.29325 0 0 0 30.21666,3.77826 l 9.8861,-29.82892 a 172.97099,172.97099 0 0 0 1.42693,0.0713 172.97099,172.97099 0 0 0 23.9289,-1.85188 l 13.80084,28.50125 a 203.29325,203.29325 0 0 0 29.40084,-7.97217 l -2.2862,-31.40474 a 172.97099,172.97099 0 0 0 22.73153,-11.31922 l 23.71796,21.10297 A 203.29325,203.29325 0 0 0 532.96,916.00016 l -14.09864,-28.07319 a 172.97099,172.97099 0 0 0 16.63607,-19.21073 l 30.05846,10.44757 a 203.29325,203.29325 0 0 0 15.01683,-26.48807 l -23.73036,-20.50738 a 172.97099,172.97099 0 0 0 7.98457,-24.14293 l 31.73044,-1.84879 a 203.29325,203.29325 0 0 0 3.77825,-30.21667 l -29.82892,-9.8861 a 172.97099,172.97099 0 0 0 0.0713,-1.42692 172.97099,172.97099 0 0 0 -1.85189,-23.9289 l 28.50124,-13.80084 a 203.29325,203.29325 0 0 0 -7.97215,-29.40084 l -31.40474,2.2862 a 172.97099,172.97099 0 0 0 -11.31923,-22.73153 l 21.10297,-23.71797 a 203.29325,203.29325 0 0 0 -18.67409,-24.05918 l -28.07319,14.09863 a 172.97099,172.97099 0 0 0 -19.21074,-16.63606 l 10.44757,-30.05847 A 203.29325,203.29325 0 0 0 485.6357,581.68117 l -20.50738,23.73035 a 172.97099,172.97099 0 0 0 -24.14293,-7.98455 l -1.84879,-31.73044 a 203.29325,203.29325 0 0 0 -30.21667,-3.77826 z M 397.6069,637.72208 A 126.92605,126.92605 0 0 1 524.5318,764.64699 126.92605,126.92605 0 0 1 397.6069,891.57189 126.92605,126.92605 0 0 1 270.682,764.64699 126.92605,126.92605 0 0 1 397.6069,637.72208 Z"
id="path18717-4" />
<path
id="path18758"
d="m 51.748325,401.28402 -9.8861,29.82892 c -0.475543,-0.0257 -0.951191,-0.0495 -1.42693,-0.0713 -8.00956,0.0625 -16.005106,0.6813 -23.92891,1.85189 L 2.7055639,404.39228 c -9.9858697,1.91835 -19.8137359,4.58322 -29.4008489,7.97217 l 2.28619,31.40474 c -7.844275,3.21103 -15.441918,6.9943 -22.73152,11.31923 l -23.71796,-21.103 c -8.475372,5.61437 -16.517661,11.85658 -24.05918,18.6741 l 14.09863,28.07319 c -6.008901,5.98701 -11.569263,12.40791 -16.636077,19.21074 l -30.058438,-10.44758 c -5.66072,8.44155 -10.68041,17.29574 -15.01683,26.48807 l 23.73035,20.50738 c -3.25027,7.84084 -5.919,15.91028 -7.98456,24.14293 l -31.73044,1.84879 c -2.01308,9.96359 -3.27604,20.06413 -3.77825,30.21664 l 29.82892,9.8861 c -0.0257,0.47554 -0.0495,0.95119 -0.0713,1.42693 0.0625,8.00955 0.68129,16.00509 1.85188,23.92889 l -28.50125,13.80084 c 1.91835,9.98587 4.58321,19.81373 7.97215,29.40084 l 31.40475,-2.28619 c 3.21102,7.84427 6.99429,15.44192 11.31922,22.73152 l -21.10296,23.71797 c 5.61437,8.47537 11.85658,16.51766 18.67409,24.05918 l 28.073175,-14.09863 c 5.987006,6.00889 12.407911,11.56925 19.21074,16.63606 l -10.44758,30.05847 c 8.441549,5.66072 17.295731,10.68041 26.48806,15.01683 l 20.50739,-23.73036 c 7.840839,3.25027 15.910282,5.91901 24.142929,7.98457 l 1.8488,31.73043 c 9.9635962,2.01309 20.064147,3.27605 30.216661,3.77826 l 9.8861,-29.82892 c 0.475543,0.0257 0.951191,0.0495 1.42693,0.0713 8.009557,-0.0625 16.005099,-0.68129 23.9289,-1.85188 l 13.80084,28.50125 c 9.985867,-1.91835 19.813731,-4.58322 29.400855,-7.97217 l -2.2862,-31.40474 c 7.84428,-3.21102 15.44192,-6.99429 22.73153,-11.31922 l 23.71796,21.10297 c 8.47538,-5.61437 16.51767,-11.85658 24.05919,-18.6741 l -14.09864,-28.07319 c 6.0089,-5.987 11.56926,-12.4079 16.63607,-19.21073 l 30.05846,10.44757 c 5.66072,-8.44155 10.68041,-17.29574 15.01683,-26.48807 l -23.73036,-20.50738 c 3.25027,-7.84084 5.91901,-15.91028 7.98457,-24.14293 l 31.73044,-1.84879 c 2.01308,-9.9636 3.27604,-20.06415 3.77825,-30.21667 l -29.82892,-9.8861 c 0.0257,-0.47554 0.0495,-0.95118 0.0713,-1.42692 -0.0625,-8.00956 -0.6813,-16.0051 -1.85189,-23.9289 l 28.50124,-13.80084 c -1.91835,-9.98587 -4.58321,-19.81373 -7.97215,-29.40084 l -31.40474,2.2862 c -3.21103,-7.84428 -6.9943,-15.44192 -11.31923,-22.73153 l 21.10297,-23.71797 c -5.61437,-8.47537 -11.85658,-16.51766 -18.67409,-24.05918 l -28.07319,14.09863 c -5.98701,-6.00889 -12.40791,-11.56925 -19.21074,-16.63606 l 10.44757,-30.05847 c -8.44155,-5.66072 -17.29572,-10.6804 -26.48805,-15.01682 l -20.50738,23.73035 c -7.84085,-3.25027 -15.910298,-5.91899 -24.142945,-7.98455 l -1.84879,-31.73044 c -9.9636,-2.01309 -20.064152,-3.27605 -30.21667,-3.77826 z"
style="display:inline;opacity:1;vector-effect:none;fill:#1a5fb4;fill-opacity:1;stroke:none;stroke-width:7.03712;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
</g>
</g>
<path
style="display:inline;opacity:0.534;vector-effect:none;fill:#1a5fb4;fill-opacity:1;stroke:none;stroke-width:1.62918;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new"
clip-path="none"
d="m 8.4765625,2676 c -1.1711695,2.8866 -0.5827763,6.3078 1.7656255,8.6562 l 48.101562,48.1016 c 3.133898,3.1339 8.178602,3.1339 11.3125,0 l 48.10156,-48.1016 c 2.3484,-2.3484 2.9368,-5.7696 1.76563,-8.6562 -0.39174,0.9655 -0.98013,1.8708 -1.76563,2.6562 l -48.10156,48.1016 c -3.133898,3.1339 -8.178602,3.1339 -11.3125,0 L 10.242188,2678.6562 C 9.4566904,2677.8708 8.8682972,2676.9655 8.4765625,2676 Z"
transform="matrix(4,0,0,4,0,-10028)"
id="rect18571-6" />
</g>
<rect
y="2402"
x="-1.5000001e-06"
height="128"
width="128"
id="rect9125-7-2"
style="display:inline;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:none;stroke-width:1.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none;paint-order:normal;enable-background:new" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 1.53125 0.0429688 l -0.277344 0.1328122 c -0.402344 0.195313 -0.847656 0.632813 -1.035156 1.011719 l -0.1523438 0.3125 l -0.0117187 5.195312 c -0.0078125 2.859376 0.0039063 5.3125 0.0234375 5.449219 c 0.046875 0.332031 0.097656 0.351563 0.914063 0.410157 c 0.433593 0.035156 0.695312 0.078124 0.800781 0.132812 c 0.523437 0.265625 0.921875 0.71875 1.082031 1.238281 c 0.074219 0.234375 0.09375 0.4375 0.09375 0.90625 c 0 0.636719 0.011719 0.679688 0.234375 0.71875 c 0.242187 0.042969 10.832031 0.035157 11.109375 -0.011719 c 0.613281 -0.101562 1.199219 -0.554687 1.496094 -1.164062 l 0.15625 -0.320312 c 0.015625 -0.101563 0 -6.253907 0 -6.253907 v -6.25 l -0.144532 -0.304687 c -0.214843 -0.453125 -0.566406 -0.816406 -1 -1.027344 l -0.359374 -0.1757812 z m 5.242188 2.9140622 c 0.09375 -0.003906 0.207031 0.042969 0.34375 0.136719 c 0.3125 0.203125 3.441406 2.90625 4.265624 3.679688 c 0.335938 0.320312 0.609376 0.65625 0.609376 0.753906 c 0 0.105468 -0.867188 0.933594 -2.164063 2.066406 c -3.035156 2.652344 -2.949219 2.59375 -3.253906 2.25 c -0.136719 -0.144531 -0.164063 -0.40625 -0.164063 -1.515625 v -1.339844 h -0.929687 c -0.816407 0 -0.9375 -0.023437 -1.027344 -0.1875 c -0.054687 -0.105469 -0.101563 -0.742187 -0.101563 -1.414062 c 0 -1.445313 0.054688 -1.558594 0.773438 -1.53125 c 0.25 0.007812 0.636719 0.011719 0.867188 0.007812 l 0.417968 -0.011719 v -1.230468 c 0 -1.167969 0.085938 -1.648438 0.363282 -1.664063 z m 0 0"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><g color="#000" fill="#2e3436"><path d="M7.188 2.281c-.094.056-.192.125-.29.19L5.566 3.803a1.684 1.684 0 11-2.17 2.17L2.332 7.037c.506-.069 1.017-.136 1.2.026.242.214.139 1.031.155 1.656.213.088.427.171.657.219.04.008.085-.007.125 0 .337-.525.683-1.288 1-1.344.322-.057.905.562 1.406.937a3.67 3.67 0 00.656-.468c-.195-.595-.594-1.369-.437-1.657.158-.29 1.019-.37 1.625-.531.028-.183.062-.371.062-.562 0-.075-.027-.146-.031-.22-.587-.217-1.435-.385-1.562-.687-.128-.302.34-1.021.593-1.593a3.722 3.722 0 00-.593-.532zm3.875 3.25c-.165.475-.305 1.086-.47 1.563-.43.047-.84.14-1.218.312-.38-.322-.787-.773-1.156-1.093a5.562 5.562 0 00-.688.468c.177.46.453 1.001.625 1.469-.298.309-.531.67-.719 1.063-.494 0-1.102-.084-1.593-.094a5.68 5.68 0 00-.219.812c.435.24 1.006.468 1.438.72-.006.093-.032.185-.032.28 0 .333.049.66.125.97-.382.304-.898.63-1.28.937.015.044.04.083.058.127l.613.613c.417-.1.868-.223 1.266-.303.248.343.532.626.875.875-.027.135-.068.283-.104.428.174-.063.34-.155.482-.297l1.432-1.432a1.994 1.994 0 01.533-3.918c.919 0 1.684.623 1.918 1.467l1.338-1.338c.06-.06.11-.124.156-.191-.035-.062-.06-.13-.1-.188.096-.152.205-.31.315-.47.017-.348-.1-.7-.37-.971l-.177-.176c-.28.192-.561.387-.83.555-.345-.233-.746-.383-1.156-.5-.077-.507-.107-1.132-.187-1.625a5.44 5.44 0 00-.875-.063zm-9.247.608c-.087.068-.173.138-.254.205l.014.035z" style="marker:none" overflow="visible"/><path d="M8.707.293a1 1 0 00-1.415 0l-6.999 7a1 1 0 000 1.413l7 7.001a1 1 0 001.415 0l7-7a1 1 0 000-1.413zm-.708 2.121l5.587 5.587L8 13.586 2.414 7.999z" style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;shape-padding:0;isolation:auto;mix-blend-mode:normal;marker:none" font-weight="400" font-family="sans-serif" overflow="visible"/></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,2 +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="#222222"><path d="m 4 5.992188 h 1 c 0.257812 0 0.527344 -0.128907 0.71875 -0.3125 l 1.28125 -1.28125 v 6.59375 h 2 v -6.59375 l 1.28125 1.28125 c 0.191406 0.183593 0.410156 0.3125 0.71875 0.3125 h 1 v -1 c 0 -0.308594 -0.089844 -0.550782 -0.28125 -0.75 l -3.71875 -3.65625 l -3.71875 3.65625 c -0.191406 0.199218 -0.28125 0.441406 -0.28125 0.75 z m 0 0"/><path d="m 4.039062 6.957031 c -1.664062 0 -3.03125 1.371094 -3.03125 3.035157 v 1.972656 c 0 1.664062 1.367188 3.035156 3.03125 3.035156 h 7.917969 c 1.664063 0 3.03125 -1.371094 3.03125 -3.035156 v -1.972656 c 0 -1.664063 -1.367187 -3.035157 -3.03125 -3.035157 h -0.957031 v 2 h 0.957031 c 0.589844 0 1.03125 0.445313 1.03125 1.035157 v 1.972656 c 0 0.589844 -0.441406 1.035156 -1.03125 1.035156 h -7.917969 c -0.589843 0 -1.03125 -0.445312 -1.03125 -1.035156 v -1.972656 c 0 -0.589844 0.441407 -1.035157 1.03125 -1.035157 h 0.960938 v -2 z m 0 0"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,4 +1,4 @@
application_id = 'cz.bugsy.roster' application_id = 'cz.vesp.roster'
scalable_dir = 'hicolor' / 'scalable' / 'apps' scalable_dir = 'hicolor' / 'scalable' / 'apps'
install_data( install_data(
@ -11,9 +11,3 @@ install_data(
symbolic_dir / ('@0@-symbolic.svg').format(application_id), symbolic_dir / ('@0@-symbolic.svg').format(application_id),
install_dir: get_option('datadir') / 'icons' / symbolic_dir install_dir: get_option('datadir') / 'icons' / symbolic_dir
) )
# Install custom export icon
install_data(
symbolic_dir / 'export-symbolic.svg',
install_dir: get_option('datadir') / 'icons' / symbolic_dir
)

View File

@ -1,6 +1,6 @@
desktop_file = i18n.merge_file( desktop_file = i18n.merge_file(
input: 'cz.bugsy.roster.desktop.in', input: 'cz.vesp.roster.desktop.in',
output: 'cz.bugsy.roster.desktop', output: 'cz.vesp.roster.desktop',
type: 'desktop', type: 'desktop',
po_dir: '../po', po_dir: '../po',
install: true, install: true,
@ -13,8 +13,8 @@ if desktop_utils.found()
endif endif
appstream_file = i18n.merge_file( appstream_file = i18n.merge_file(
input: 'cz.bugsy.roster.metainfo.xml.in', input: 'cz.vesp.roster.metainfo.xml.in',
output: 'cz.bugsy.roster.metainfo.xml', output: 'cz.vesp.roster.metainfo.xml',
po_dir: '../po', po_dir: '../po',
install: true, install: true,
install_dir: get_option('datadir') / 'metainfo' install_dir: get_option('datadir') / 'metainfo'
@ -24,7 +24,7 @@ appstreamcli = find_program('appstreamcli', required: false, disabler: true)
test('Validate appstream file', appstreamcli, test('Validate appstream file', appstreamcli,
args: ['validate', '--no-net', '--explain', appstream_file]) args: ['validate', '--no-net', '--explain', appstream_file])
install_data('cz.bugsy.roster.gschema.xml', install_data('cz.vesp.roster.gschema.xml',
install_dir: get_option('datadir') / 'glib-2.0' / 'schemas' install_dir: get_option('datadir') / 'glib-2.0' / 'schemas'
) )
@ -37,8 +37,8 @@ test('Validate schema file',
service_conf = configuration_data() service_conf = configuration_data()
service_conf.set('bindir', get_option('prefix') / get_option('bindir')) service_conf.set('bindir', get_option('prefix') / get_option('bindir'))
configure_file( configure_file(
input: 'cz.bugsy.roster.service.in', input: 'cz.vesp.roster.service.in',
output: 'cz.bugsy.roster.service', output: 'cz.vesp.roster.service',
configuration: service_conf, configuration: service_conf,
install_dir: get_option('datadir') / 'dbus-1' / 'services' install_dir: get_option('datadir') / 'dbus-1' / 'services'
) )

View File

@ -1,158 +0,0 @@
#!/usr/bin/env python3
"""
Example script demonstrating sensitive variable usage.
This script shows how to:
1. Create a project with variables
2. Mark some variables as sensitive
3. Set values for both regular and sensitive variables
4. Retrieve values from both storages
5. Use variables in request substitution
Run this from the project root after building the app.
"""
import sys
import os
# Add src to path for testing
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from roster.project_manager import ProjectManager
from roster.models import HttpRequest
from roster.variable_substitution import VariableSubstitution
def main():
print("=" * 60)
print("Sensitive Variables Example")
print("=" * 60)
pm = ProjectManager()
# Create a test project
print("\n1. Creating test project...")
project = pm.add_project("API Test Project")
project_id = project.id
print(f" Created project: {project.name} (ID: {project_id})")
# Add environments
print("\n2. Adding environments...")
env_prod = pm.add_environment(project_id, "Production")
env_dev = pm.add_environment(project_id, "Development")
print(f" - Production (ID: {env_prod.id})")
print(f" - Development (ID: {env_dev.id})")
# Add variables
print("\n3. Adding variables...")
pm.add_variable(project_id, "base_url")
pm.add_variable(project_id, "api_key")
pm.add_variable(project_id, "timeout")
print(" - base_url (regular)")
print(" - api_key (regular for now)")
print(" - timeout (regular)")
# Set values for regular variables
print("\n4. Setting values for regular variables...")
pm.set_variable_value(project_id, env_prod.id, "base_url", "https://api.example.com")
pm.set_variable_value(project_id, env_dev.id, "base_url", "https://dev.api.example.com")
pm.set_variable_value(project_id, env_prod.id, "timeout", "30")
pm.set_variable_value(project_id, env_dev.id, "timeout", "60")
print(" ✓ Set base_url for both environments")
print(" ✓ Set timeout for both environments")
# Set API key values (still regular)
print("\n5. Setting API key values (in JSON - NOT SECURE YET)...")
pm.set_variable_value(project_id, env_prod.id, "api_key", "prod-secret-key-12345")
pm.set_variable_value(project_id, env_dev.id, "api_key", "dev-secret-key-67890")
print(" ⚠ API keys stored in plain JSON file!")
# Mark API key as sensitive
print("\n6. Marking api_key as SENSITIVE...")
success = pm.mark_variable_as_sensitive(project_id, "api_key")
if success:
print(" ✓ api_key is now stored in GNOME Keyring (encrypted)")
print(" ✓ Values moved from JSON to keyring")
print(" ✓ JSON now contains empty placeholders")
else:
print(" ✗ Failed to mark as sensitive")
return
# Verify storage
print("\n7. Verifying storage...")
is_sensitive = pm.is_variable_sensitive(project_id, "api_key")
print(f" - api_key is sensitive: {is_sensitive}")
# Retrieve values
print("\n8. Retrieving values...")
base_url_prod = pm.get_variable_value(project_id, env_prod.id, "base_url")
api_key_prod = pm.get_variable_value(project_id, env_prod.id, "api_key")
api_key_dev = pm.get_variable_value(project_id, env_dev.id, "api_key")
print(f" - Production base_url: {base_url_prod}")
print(f" - Production api_key: {api_key_prod} (from keyring)")
print(f" - Development api_key: {api_key_dev} (from keyring)")
# Use in request substitution
print("\n9. Using in request substitution...")
request = HttpRequest(
method="GET",
url="{{base_url}}/users",
headers={
"Authorization": "Bearer {{api_key}}",
"X-Timeout": "{{timeout}}"
},
body=""
)
print(f" Original request URL: {request.url}")
print(f" Original request headers: {request.headers}")
# Get environment with secrets
env_with_secrets = pm.get_environment_with_secrets(project_id, env_prod.id)
# Substitute variables
vs = VariableSubstitution()
substituted_request, undefined = vs.substitute_request(request, env_with_secrets)
print(f"\n Substituted request URL: {substituted_request.url}")
print(f" Substituted headers: {substituted_request.headers}")
print(f" Undefined variables: {undefined}")
# Demonstrate file storage
print("\n10. Checking file storage...")
projects = pm.load_projects()
for p in projects:
if p.id == project_id:
print(f" Variables: {p.variable_names}")
print(f" Sensitive variables: {p.sensitive_variables}")
for env in p.environments:
if env.id == env_prod.id:
print(f" Production variables in JSON: {env.variables}")
print(f" Notice: api_key is empty (value in keyring)")
# Cleanup
print("\n11. Cleanup...")
response = input(" Delete test project? (y/n): ")
if response.lower() == 'y':
pm.delete_project(project_id)
print(" ✓ Project deleted (secrets also removed from keyring)")
else:
print(f" Project kept. ID: {project_id}")
print(" You can view secrets in 'Passwords and Keys' app")
print("\n" + "=" * 60)
print("Example completed!")
print("=" * 60)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\nInterrupted by user")
sys.exit(1)
except Exception as e:
print(f"\n\nError: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@ -1,5 +1,5 @@
project('roster', project('roster',
version: '0.8.2', version: '0.3.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', ],
) )

View File

@ -1,21 +1,8 @@
# List of source files containing translatable strings. # List of source files containing translatable strings.
# Please keep this file sorted alphabetically. # Please keep this file sorted alphabetically.
data/cz.bugsy.roster.desktop.in data/cz.vesp.roster.desktop.in
data/cz.bugsy.roster.metainfo.xml.in data/cz.vesp.roster.metainfo.xml.in
data/cz.bugsy.roster.gschema.xml data/cz.vesp.roster.gschema.xml
src/environments_dialog.py
src/environments-dialog.ui
src/export_dialog.py
src/export-dialog.ui
src/icon_picker_dialog.py
src/icon-picker-dialog.ui
src/main.py src/main.py
src/main-window.ui
src/preferences_dialog.py
src/preferences-dialog.ui
src/project_manager.py
src/request_tab_widget.py
src/secret_manager.py
src/shortcuts-dialog.ui
src/tab_manager.py
src/window.py src/window.py
src/window.ui

View File

@ -1,182 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the roster package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: roster\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-13 17:55+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"
#: data/cz.bugsy.roster.desktop.in:2 data/cz.bugsy.roster.metainfo.xml.in:7
msgid "Roster"
msgstr ""
#: data/cz.bugsy.roster.desktop.in:3 data/cz.bugsy.roster.metainfo.xml.in:8
msgid "HTTP client for API testing"
msgstr ""
#: data/cz.bugsy.roster.desktop.in:9
msgid "HTTP;REST;API;Client;Request;JSON;"
msgstr ""
#: data/cz.bugsy.roster.metainfo.xml.in:10
msgid ""
"Roster is a modern HTTP client for testing and debugging REST APIs. It "
"provides a clean, GNOME-native interface for making HTTP requests and "
"inspecting responses."
msgstr ""
#: data/cz.bugsy.roster.metainfo.xml.in:11
msgid ""
"Note: Application-specific icons included with this project are licensed "
"under CC-BY-SA-3.0. GNOME system icons are provided by the system and are "
"not distributed with this application. All other components are licensed "
"under GPL-3.0-or-later."
msgstr ""
#: data/cz.bugsy.roster.metainfo.xml.in:15
msgid "Pavel Baksy"
msgstr ""
#: data/cz.bugsy.roster.metainfo.xml.in:32
#: data/cz.bugsy.roster.metainfo.xml.in:36
msgid "A caption"
msgstr ""
#: data/cz.bugsy.roster.gschema.xml:6
msgid "Force TLS verification"
msgstr ""
#: data/cz.bugsy.roster.gschema.xml:7
msgid ""
"When enabled, TLS certificates will be verified. Disable to test endpoints "
"with self-signed or invalid certificates."
msgstr ""
#: data/cz.bugsy.roster.gschema.xml:11
msgid "Request timeout"
msgstr ""
#: data/cz.bugsy.roster.gschema.xml:12
msgid "Timeout for HTTP requests in seconds"
msgstr ""
#. Translators: Replace "translator-credits" with your name/username, and optionally an email or URL.
#: src/main.py:79
msgid "translator-credits"
msgstr ""
#: src/main-window.ui:132
msgid "Main Menu"
msgstr ""
#: src/main-window.ui:234
msgid "_Preferences"
msgstr ""
#: src/main-window.ui:238
msgid "_Keyboard Shortcuts"
msgstr ""
#: src/main-window.ui:242
msgid "_About Roster"
msgstr ""
#: src/preferences-dialog.ui:7
msgid "Preferences"
msgstr ""
#: src/preferences-dialog.ui:14
msgid "General"
msgstr ""
#: src/preferences-dialog.ui:19
msgid "Network"
msgstr ""
#: src/preferences-dialog.ui:20
msgid "HTTP connection settings"
msgstr ""
#: src/preferences-dialog.ui:24
msgid "Force TLS Verification"
msgstr ""
#: src/preferences-dialog.ui:25
msgid ""
"Verify TLS certificates for HTTPS connections. Disable to test endpoints "
"with self-signed certificates."
msgstr ""
#: src/preferences-dialog.ui:31
msgid "Request Timeout"
msgstr ""
#: src/preferences-dialog.ui:32
msgid "Maximum time to wait for a response (in seconds)"
msgstr ""
#: src/preferences-dialog.ui:49
msgid "History"
msgstr ""
#: src/preferences-dialog.ui:50
msgid "Manage request history"
msgstr ""
#: src/preferences-dialog.ui:54
msgid "Clear All History"
msgstr ""
#: src/preferences-dialog.ui:55
msgid "Remove all saved request and response history"
msgstr ""
#: src/preferences-dialog.ui:58
msgid "Clear"
msgstr ""
#: src/shortcuts-dialog.ui:13
msgctxt "shortcut window"
msgid "New Tab"
msgstr ""
#: src/shortcuts-dialog.ui:19
msgctxt "shortcut window"
msgid "Close Tab"
msgstr ""
#: src/shortcuts-dialog.ui:25
msgctxt "shortcut window"
msgid "Save Request"
msgstr ""
#: src/shortcuts-dialog.ui:31
msgctxt "shortcut window"
msgid "Focus URL Field"
msgstr ""
#: src/shortcuts-dialog.ui:37
msgctxt "shortcut window"
msgid "Send Request"
msgstr ""
#: src/shortcuts-dialog.ui:43
msgctxt "shortcut window"
msgid "Show Shortcuts"
msgstr ""
#: src/shortcuts-dialog.ui:49
msgctxt "shortcut window"
msgid "Quit"
msgstr ""

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

View File

@ -7,7 +7,6 @@
# 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
@ -18,38 +17,6 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# Application Version
# Set at runtime by main.py from the version passed by the launcher script
VERSION = "0.0.0" # Fallback for development/testing
# UI Layout Constants
UI_PANE_REQUEST_RESPONSE_POSITION = 510 # Initial position for request/response split
UI_PANE_RESPONSE_DETAILS_POSITION = 400 # Initial position for response body/headers split
UI_PANE_RESULTS_PANEL_POSITION = 120 # Initial position for script results panel
UI_PANE_SCRIPTS_POSITION = 300 # Initial position for scripts tab panes
UI_SCRIPT_RESULTS_PANEL_HEIGHT = 150 # Height reserved for script results panel
UI_TOAST_TIMEOUT_SECONDS = 3 # Duration to show toast notifications
# Debounce/Delay Timeouts (milliseconds)
DEBOUNCE_VARIABLE_INDICATORS_MS = 500 # Delay before updating variable indicators
DELAY_TAB_CREATION_MS = 50 # Delay before creating new tab after last one closes
# Data Display Limits
DISPLAY_VARIABLE_MAX_LENGTH = 50 # Max characters to display for variable values
DISPLAY_VARIABLE_TRUNCATE_SUFFIX = "..." # Suffix for truncated variable values
DISPLAY_URL_NAME_MAX_LENGTH = 30 # Max characters for URL in generated tab names
# Data Storage Limits
HISTORY_MAX_ENTRIES = 100 # Maximum number of history entries to keep
PROJECT_NAME_MAX_LENGTH = 100 # Maximum length for project names
REQUEST_NAME_MAX_LENGTH = 200 # Maximum length for request names
# HTTP Client Settings
HTTP_DEFAULT_TIMEOUT_SECONDS = 30 # Default timeout for HTTP requests
# Script Execution Settings
SCRIPT_EXECUTION_TIMEOUT_SECONDS = 5 # Maximum execution time for scripts
# 36 symbolic icons for projects (6x6 grid) # 36 symbolic icons for projects (6x6 grid)
PROJECT_ICONS = [ PROJECT_ICONS = [
# Row 1: Folders & Organization # Row 1: Folders & Organization

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">1100</property> <property name="content-width">900</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>

View File

@ -7,7 +7,6 @@
# 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
@ -25,7 +24,7 @@ from .widgets.environment_header_row import EnvironmentHeaderRow
from .widgets.variable_data_row import VariableDataRow from .widgets.variable_data_row import VariableDataRow
@Gtk.Template(resource_path='/cz/bugsy/roster/environments-dialog.ui') @Gtk.Template(resource_path='/cz/vesp/roster/environments-dialog.ui')
class EnvironmentsDialog(Adw.Dialog): class EnvironmentsDialog(Adw.Dialog):
"""Dialog for managing project environments and variables.""" """Dialog for managing project environments and variables."""
@ -80,8 +79,6 @@ class EnvironmentsDialog(Adw.Dialog):
var_name, var_name,
self.project.environments, self.project.environments,
self.size_group, self.size_group,
self.project,
self.project_manager,
update_timestamp=update_timestamp update_timestamp=update_timestamp
) )
row.connect('variable-changed', row.connect('variable-changed',
@ -90,8 +87,6 @@ class EnvironmentsDialog(Adw.Dialog):
self._on_variable_remove, var_name) self._on_variable_remove, var_name)
row.connect('value-changed', row.connect('value-changed',
self._on_value_changed, var_name) self._on_value_changed, var_name)
row.connect('sensitivity-changed',
self._on_sensitivity_changed, var_name)
self.table_container.append(row) self.table_container.append(row)
self.data_rows.append(row) self.data_rows.append(row)
@ -169,12 +164,10 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response): def on_response(dlg, response):
if response == "delete": if response == "delete":
def on_delete_complete(): self.project_manager.delete_variable(self.project.id, var_name)
self._reload_project() self._reload_project()
self._populate_table() self._populate_table()
self.emit('environments-updated') self.emit('environments-updated')
self.project_manager.delete_variable(self.project.id, var_name, on_delete_complete)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@ -183,12 +176,10 @@ class EnvironmentsDialog(Adw.Dialog):
"""Handle variable name change.""" """Handle variable name change."""
new_name = row.get_variable_name() new_name = row.get_variable_name()
if new_name and new_name != old_name and new_name not in self.project.variable_names: if new_name and new_name != old_name and new_name not in self.project.variable_names:
def on_rename_complete(): self.project_manager.rename_variable(self.project.id, old_name, new_name)
self._reload_project() self._reload_project()
self._populate_table() self._populate_table()
self.emit('environments-updated') self.emit('environments-updated')
self.project_manager.rename_variable(self.project.id, old_name, new_name, on_rename_complete)
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_add_environment_clicked(self, button): def on_add_environment_clicked(self, button):
@ -271,38 +262,19 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response): def on_response(dlg, response):
if response == "delete": if response == "delete":
def on_delete_complete(): self.project_manager.delete_environment(self.project.id, environment.id)
self._reload_project() self._reload_project()
self._populate_table() self._populate_table()
self.emit('environments-updated') self.emit('environments-updated')
self.project_manager.delete_environment(self.project.id, environment.id, on_delete_complete)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
def _on_value_changed(self, widget, env_id, value, var_name): def _on_value_changed(self, widget, env_id, value, var_name):
"""Handle value change in table cell.""" """Handle value change in table cell."""
# Note: value is already stored by VariableDataRow using set_variable_value self.project_manager.update_environment_variable(self.project.id, env_id, var_name, value)
# This handler is just for emitting the update signal
self.emit('environments-updated') self.emit('environments-updated')
def _on_sensitivity_changed(self, widget, is_sensitive, var_name):
"""Handle variable sensitivity toggle."""
def on_complete(success):
if success:
# Reload and refresh UI
self._reload_project()
self._populate_table()
self.emit('environments-updated')
if is_sensitive:
# Mark as sensitive (move to keyring)
self.project_manager.mark_variable_as_sensitive(self.project.id, var_name, on_complete)
else:
# Mark as non-sensitive (move to JSON)
self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name, on_complete)
def _reload_project(self): def _reload_project(self):
"""Reload project data from manager.""" """Reload project data from manager."""
projects = self.project_manager.load_projects() projects = self.project_manager.load_projects()

View File

@ -1,60 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="Adw" version="1.0"/>
<template class="ExportDialog" parent="AdwDialog">
<property name="title">Export Request</property>
<property name="content-width">600</property>
<property name="content-height">400</property>
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar">
<property name="show-title">true</property>
<child type="end">
<object class="GtkButton" id="copy_button">
<property name="label">Copy</property>
<property name="tooltip-text">Copy to Clipboard</property>
<signal name="clicked" handler="on_copy_clicked"/>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</child>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">12</property>
<property name="margin-bottom">12</property>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<property name="hexpand">true</property>
<child>
<object class="GtkTextView" id="export_textview">
<property name="editable">false</property>
<property name="monospace">true</property>
<property name="wrap-mode">none</property>
<property name="left-margin">12</property>
<property name="right-margin">12</property>
<property name="top-margin">12</property>
<property name="bottom-margin">12</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</template>
</interface>

View File

@ -1,90 +0,0 @@
# export_dialog.py
#
# Copyright 2025 Pavel Baksy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from gi.repository import Adw, Gtk, Gdk, GLib
import logging
logger = logging.getLogger(__name__)
@Gtk.Template(resource_path='/cz/bugsy/roster/export-dialog.ui')
class ExportDialog(Adw.Dialog):
"""Dialog for exporting HTTP requests in various formats."""
__gtype_name__ = 'ExportDialog'
export_textview = Gtk.Template.Child()
copy_button = Gtk.Template.Child()
def __init__(self, request, **kwargs):
"""
Initialize export dialog.
Args:
request: HttpRequest to export (should have variables substituted)
"""
super().__init__(**kwargs)
self.request = request
self._populate_export()
def _populate_export(self):
"""Generate and display the export content."""
try:
# Get cURL exporter
from .exporters import ExporterRegistry
exporter = ExporterRegistry.get_exporter('curl')
# Generate cURL command
curl_command = exporter.export(self.request)
# Display in textview
buffer = self.export_textview.get_buffer()
buffer.set_text(curl_command)
except Exception as e:
logger.error(f"Export generation failed: {e}")
buffer = self.export_textview.get_buffer()
buffer.set_text(f"Error generating export: {str(e)}")
@Gtk.Template.Callback()
def on_copy_clicked(self, button):
"""Copy export content to clipboard."""
# Get text from buffer
buffer = self.export_textview.get_buffer()
start = buffer.get_start_iter()
end = buffer.get_end_iter()
text = buffer.get_text(start, end, False)
# Copy to clipboard using Gdk.Clipboard API
display = Gdk.Display.get_default()
clipboard = display.get_clipboard()
clipboard.set(text)
# Visual feedback: temporarily change button label
original_label = button.get_label()
button.set_label("Copied!")
# Reset after 2 seconds
def reset_label():
button.set_label(original_label)
return False # Don't repeat
GLib.timeout_add(2000, reset_label)
logger.debug("Export copied to clipboard")

View File

@ -1,24 +0,0 @@
# exporters package
#
# Copyright 2025 Pavel Baksy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from .registry import ExporterRegistry
from .base_exporter import BaseExporter
from .curl_exporter import CurlExporter
__all__ = ['ExporterRegistry', 'BaseExporter', 'CurlExporter']

View File

@ -1,50 +0,0 @@
# base_exporter.py
#
# Copyright 2025 Pavel Baksy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from abc import ABC, abstractmethod
from ..models import HttpRequest
class BaseExporter(ABC):
"""Abstract base class for request exporters."""
@abstractmethod
def export(self, request: HttpRequest) -> str:
"""
Export HTTP request to target format.
Args:
request: HttpRequest with method, url, headers, body
Returns:
Formatted export string
"""
pass
@property
@abstractmethod
def format_name(self) -> str:
"""Human-readable format name (e.g., 'cURL', 'Insomnia')."""
pass
@property
@abstractmethod
def file_extension(self) -> str:
"""File extension for export (e.g., 'sh', 'json')."""
pass

View File

@ -1,70 +0,0 @@
# curl_exporter.py
#
# Copyright 2025 Pavel Baksy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import shlex
from .base_exporter import BaseExporter
from ..models import HttpRequest
class CurlExporter(BaseExporter):
"""Export HTTP requests as cURL commands."""
@property
def format_name(self) -> str:
return "cURL"
@property
def file_extension(self) -> str:
return "sh"
def export(self, request: HttpRequest) -> str:
"""
Generate cURL command with proper shell escaping.
Format:
curl -X POST \
'https://api.example.com/endpoint' \
-H 'Content-Type: application/json' \
--data '{"key": "value"}'
"""
parts = ["curl"]
# Add HTTP method (skip if GET - it's default)
if request.method != "GET":
parts.append(f"-X {request.method}")
# Add URL (always quoted for safety)
parts.append(shlex.quote(request.url))
# Add headers
for key, value in request.headers.items():
header_str = f"{key}: {value}"
parts.append(f"-H {shlex.quote(header_str)}")
# Add body for POST/PUT/PATCH/DELETE methods
if request.body and request.method in ["POST", "PUT", "PATCH", "DELETE"]:
parts.append(f"--data {shlex.quote(request.body)}")
# Format as multiline with backslash continuations
return " \\\n ".join(parts)
# Auto-register on import
from .registry import ExporterRegistry
ExporterRegistry.register('curl', CurlExporter)

View File

@ -1,46 +0,0 @@
# registry.py
#
# Copyright 2025 Pavel Baksy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Dict, List, Tuple, Type
from .base_exporter import BaseExporter
class ExporterRegistry:
"""Registry for managing export formats."""
_exporters: Dict[str, Type[BaseExporter]] = {}
@classmethod
def register(cls, format_id: str, exporter_class: Type[BaseExporter]):
"""Register an exporter class."""
cls._exporters[format_id] = exporter_class
@classmethod
def get_exporter(cls, format_id: str) -> BaseExporter:
"""Get exporter instance by format ID."""
exporter_class = cls._exporters.get(format_id)
if not exporter_class:
raise ValueError(f"Unknown export format: {format_id}")
return exporter_class()
@classmethod
def get_all_formats(cls) -> List[Tuple[str, str]]:
"""Get all registered formats as [(id, name), ...]."""
return [(format_id, cls._exporters[format_id]().format_name)
for format_id in cls._exporters.keys()]

View File

@ -7,7 +7,6 @@
# 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
@ -20,16 +19,12 @@
import json import json
import os import os
import logging
from pathlib import Path from pathlib import Path
from typing import List from typing import List
import gi import gi
gi.require_version('GLib', '2.0') gi.require_version('GLib', '2.0')
from gi.repository import GLib from gi.repository import GLib
from .models import HistoryEntry from .models import HistoryEntry
from .constants import HISTORY_MAX_ENTRIES
logger = logging.getLogger(__name__)
class HistoryManager: class HistoryManager:
@ -37,9 +32,9 @@ class HistoryManager:
def __init__(self): def __init__(self):
# Use XDG config directory (works for both Flatpak and native) # Use XDG config directory (works for both Flatpak and native)
# Flatpak: ~/.var/app/cz.bugsy.roster/config/cz.bugsy.roster # Flatpak: ~/.var/app/cz.vesp.roster/config/cz.vesp.roster
# Native: ~/.config/cz.bugsy.roster # Native: ~/.config/cz.vesp.roster
self.config_dir = Path(GLib.get_user_config_dir()) / 'cz.bugsy.roster' self.config_dir = Path(GLib.get_user_config_dir()) / 'cz.vesp.roster'
self.history_file = self.config_dir / 'history.json' self.history_file = self.config_dir / 'history.json'
self._ensure_config_dir() self._ensure_config_dir()
@ -58,7 +53,7 @@ class HistoryManager:
return [HistoryEntry.from_dict(entry) for entry in data.get('entries', [])] return [HistoryEntry.from_dict(entry) for entry in data.get('entries', [])]
except Exception as e: except Exception as e:
logger.error(f"Error loading history: {e}") print(f"Error loading history: {e}")
return [] return []
def save_history(self, entries: List[HistoryEntry]): def save_history(self, entries: List[HistoryEntry]):
@ -72,23 +67,27 @@ class HistoryManager:
with open(self.history_file, 'w') as f: with open(self.history_file, 'w') as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
except Exception as e: except Exception as e:
logger.error(f"Error saving history: {e}") print(f"Error saving history: {e}")
def add_entry(self, entry: HistoryEntry): def add_entry(self, entry: HistoryEntry):
"""Add new entry to history and save.""" """Add new entry to history and save."""
entries = self.load_history() entries = self.load_history()
entries.insert(0, entry) # Most recent first entries.insert(0, entry) # Most recent first
# Limit history size # Limit history size to 100 entries
entries = entries[:HISTORY_MAX_ENTRIES] entries = entries[:100]
self.save_history(entries) self.save_history(entries)
def delete_entry(self, entry: HistoryEntry): def delete_entry(self, entry: HistoryEntry):
"""Delete a specific entry from history by ID.""" """Delete a specific entry from history."""
entries = self.load_history() entries = self.load_history()
# Filter out the entry by comparing unique IDs # Filter out the entry by comparing timestamps and URLs
entries = [e for e in entries if e.id != entry.id] entries = [e for e in entries if not (
e.timestamp == entry.timestamp and
e.request.url == entry.request.url and
e.request.method == entry.request.method
)]
self.save_history(entries) self.save_history(entries)
def clear_history(self): def clear_history(self):

View File

@ -34,7 +34,7 @@ class HttpClient:
self.session = Soup.Session.new() self.session = Soup.Session.new()
# Load settings # Load settings
self.settings = Gio.Settings.new('cz.bugsy.roster') self.settings = Gio.Settings.new('cz.vesp.roster')
# Initialize TLS verification flag # Initialize TLS verification flag
self.force_tls_verification = True self.force_tls_verification = True
@ -153,24 +153,15 @@ class HttpClient:
# Format headers in HTTPie-style output (HTTP/1.1 200 OK\nHeader: value\n...) # Format headers in HTTPie-style output (HTTP/1.1 200 OK\nHeader: value\n...)
headers_text = self._format_headers(msg, status_code, status_text) headers_text = self._format_headers(msg, status_code, status_text)
# Get raw body data for size calculation
body_bytes = bytes_data.get_data()
# Calculate total response size (headers + body)
headers_size = len(headers_text.encode('utf-8'))
body_size = len(body_bytes)
total_size = headers_size + body_size
# Decode body with error handling # Decode body with error handling
body = body_bytes.decode('utf-8', errors='replace') body = bytes_data.get_data().decode('utf-8', errors='replace')
return HttpResponse( return HttpResponse(
status_code=status_code, status_code=status_code,
status_text=status_text, status_text=status_text,
headers=headers_text, headers=headers_text,
body=body.strip(), body=body.strip(),
response_time_ms=response_time, response_time_ms=response_time
response_size_bytes=total_size
) )
def _format_headers(self, msg: Soup.Message, status_code: int, status_text: str) -> str: def _format_headers(self, msg: Soup.Message, status_code: int, status_text: str) -> str:
@ -235,8 +226,7 @@ class HttpClient:
error_str = str(error).lower() error_str = str(error).lower()
if "timeout" in error_str: if "timeout" in error_str:
timeout_seconds = self.session.get_timeout() return "Request timeout (30 seconds)"
return f"Request timeout ({timeout_seconds} seconds)"
elif "resolve" in error_str: elif "resolve" in error_str:
return f"Could not resolve hostname: {error}" return f"Could not resolve hostname: {error}"
elif "connect" in error_str: elif "connect" in error_str:

View File

@ -7,7 +7,6 @@
# 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
@ -22,7 +21,7 @@ from gi.repository import Adw, Gtk, GObject
from .constants import PROJECT_ICONS from .constants import PROJECT_ICONS
@Gtk.Template(resource_path='/cz/bugsy/roster/icon-picker-dialog.ui') @Gtk.Template(resource_path='/cz/vesp/roster/icon-picker-dialog.ui')
class IconPickerDialog(Adw.Dialog): class IconPickerDialog(Adw.Dialog):
"""Dialog for selecting a project icon.""" """Dialog for selecting a project icon."""

View File

@ -7,11 +7,9 @@
<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="content"> <property name="content">
<object class="AdwToastOverlay" id="toast_overlay"> <object class="GtkPaned" id="main_pane">
<property name="child">
<object class="GtkPaned" id="main_pane">
<property name="orientation">horizontal</property> <property name="orientation">horizontal</property>
<property name="position">180</property> <property name="position">300</property>
<property name="shrink-start-child">False</property> <property name="shrink-start-child">False</property>
<property name="resize-start-child">True</property> <property name="resize-start-child">True</property>
<property name="shrink-end-child">False</property> <property name="shrink-end-child">False</property>
@ -32,9 +30,8 @@
<child type="start"> <child type="start">
<object class="GtkLabel"> <object class="GtkLabel">
<property name="label">Projects</property> <property name="label">Projects</property>
<property name="margin-start">6</property>
<style> <style>
<class name="title"/> <class name="heading"/>
</style> </style>
</object> </object>
</child> </child>
@ -97,17 +94,6 @@
</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>
<!-- Right side buttons --> <!-- Right side buttons -->
<child type="end"> <child type="end">
<object class="GtkBox"> <object class="GtkBox">
@ -160,8 +146,7 @@
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<property name="position">600</property> <property name="position">600</property>
<property name="shrink-start-child">False</property> <property name="shrink-start-child">False</property>
<!-- Don't allow history panel to shrink below its minimum size --> <property name="shrink-end-child">True</property>
<property name="shrink-end-child">False</property>
<property name="resize-start-child">True</property> <property name="resize-start-child">True</property>
<property name="resize-end-child">True</property> <property name="resize-end-child">True</property>
@ -176,8 +161,6 @@
<property name="end-child"> <property name="end-child">
<object class="GtkBox"> <object class="GtkBox">
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<!-- Set minimum height to ensure header is always visible -->
<property name="height-request">50</property>
<!-- History Header --> <!-- History Header -->
<child> <child>
@ -205,7 +188,7 @@
<child> <child>
<object class="GtkScrolledWindow"> <object class="GtkScrolledWindow">
<property name="vexpand">True</property> <property name="vexpand">True</property>
<property name="min-content-height">50</property> <property name="min-content-height">150</property>
<child> <child>
<object class="GtkListBox" id="history_listbox"> <object class="GtkListBox" id="history_listbox">
@ -223,8 +206,6 @@
</object> </object>
</property> </property>
</object> </object>
</property>
</object>
</property> </property>
</template> </template>

View File

@ -26,16 +26,15 @@ gi.require_version('Adw', '1')
from gi.repository import Gtk, Gio, Adw from gi.repository import Gtk, Gio, Adw
from .window import RosterWindow from .window import RosterWindow
from .preferences_dialog import PreferencesDialog from .preferences_dialog import PreferencesDialog
from . import constants
class RosterApplication(Adw.Application): class RosterApplication(Adw.Application):
"""The main application singleton class.""" """The main application singleton class."""
def __init__(self, version='0.0.0'): def __init__(self, version='0.0.0'):
super().__init__(application_id='cz.bugsy.roster', super().__init__(application_id='cz.vesp.roster',
flags=Gio.ApplicationFlags.DEFAULT_FLAGS, flags=Gio.ApplicationFlags.DEFAULT_FLAGS,
resource_base_path='/cz/bugsy/roster') resource_base_path='/cz/vesp/roster')
self.version = version self.version = version
self.create_action('quit', lambda *_: self.quit(), ['<control>q']) self.create_action('quit', lambda *_: self.quit(), ['<control>q'])
self.create_action('about', self.on_about_action) self.create_action('about', self.on_about_action)
@ -56,25 +55,14 @@ class RosterApplication(Adw.Application):
def on_about_action(self, *args): def on_about_action(self, *args):
"""Callback for the app.about action.""" """Callback for the app.about action."""
about = Adw.AboutDialog(application_name='Roster', about = Adw.AboutDialog(application_name='Roster',
application_icon='cz.bugsy.roster', application_icon='cz.vesp.roster',
developer_name='Pavel Baksy', developer_name='Pavel Baksy',
version=self.version, version=self.version,
developers=['Pavel Baksy'], developers=['Pavel Baksy'],
copyright='© 2025 Pavel Baksy', copyright='© 2025 Pavel Baksy',
comments='HTTP client for testing APIs') comments='HTTP client for testing APIs')
about.set_website('https://git.bugsy.cz/beval/roster') about.set_website('https://github.com/pavelb/roster')
about.set_issue_url('https://git.bugsy.cz/beval/roster/issues')
about.set_license_type(Gtk.License.GPL_3_0) about.set_license_type(Gtk.License.GPL_3_0)
# Add legal notice about icon licensing
about.add_legal_section(
'Application Icons',
None,
Gtk.License.CUSTOM,
'Application icons are licensed under CC-BY-SA-3.0\n'
'https://creativecommons.org/licenses/by-sa/3.0/'
)
# Translators: Replace "translator-credits" with your name/username, and optionally an email or URL. # Translators: Replace "translator-credits" with your name/username, and optionally an email or URL.
about.set_translator_credits(_('translator-credits')) about.set_translator_credits(_('translator-credits'))
about.present(self.props.active_window) about.present(self.props.active_window)
@ -89,7 +77,7 @@ class RosterApplication(Adw.Application):
def on_shortcuts_action(self, widget, _): def on_shortcuts_action(self, widget, _):
"""Callback for the app.shortcuts action.""" """Callback for the app.shortcuts action."""
builder = Gtk.Builder.new_from_resource('/cz/bugsy/roster/shortcuts-dialog.ui') builder = Gtk.Builder.new_from_resource('/cz/vesp/roster/shortcuts-dialog.ui')
shortcuts_window = builder.get_object('shortcuts_dialog') shortcuts_window = builder.get_object('shortcuts_dialog')
shortcuts_window.set_transient_for(self.props.active_window) shortcuts_window.set_transient_for(self.props.active_window)
shortcuts_window.present() shortcuts_window.present()
@ -112,7 +100,5 @@ class RosterApplication(Adw.Application):
def main(version): def main(version):
"""The application's entry point.""" """The application's entry point."""
# Set the version constant for use throughout the application
constants.VERSION = version
app = RosterApplication(version=version) app = RosterApplication(version=version)
return app.run(sys.argv) return app.run(sys.argv)

View File

@ -35,12 +35,10 @@ roster_sources = [
'http_client.py', 'http_client.py',
'history_manager.py', 'history_manager.py',
'project_manager.py', 'project_manager.py',
'secret_manager.py',
'tab_manager.py', 'tab_manager.py',
'constants.py', 'constants.py',
'icon_picker_dialog.py', 'icon_picker_dialog.py',
'environments_dialog.py', 'environments_dialog.py',
'export_dialog.py',
'preferences_dialog.py', 'preferences_dialog.py',
'request_tab_widget.py', 'request_tab_widget.py',
'script_executor.py', 'script_executor.py',
@ -48,16 +46,6 @@ roster_sources = [
install_data(roster_sources, install_dir: moduledir) install_data(roster_sources, install_dir: moduledir)
# Install exporters submodule
exporters_sources = [
'exporters/__init__.py',
'exporters/base_exporter.py',
'exporters/curl_exporter.py',
'exporters/registry.py',
]
install_data(exporters_sources, install_dir: moduledir / 'exporters')
# Install widgets submodule # Install widgets submodule
widgets_sources = [ widgets_sources = [
'widgets/__init__.py', 'widgets/__init__.py',

View File

@ -7,7 +7,6 @@
# 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
@ -20,7 +19,6 @@
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import Dict, Optional, List from typing import Dict, Optional, List
from . import constants
@dataclass @dataclass
@ -32,16 +30,6 @@ class HttpRequest:
body: str # Raw text body body: str # Raw text body
syntax: str = "RAW" # Syntax highlighting: "RAW", "JSON", or "XML" syntax: str = "RAW" # Syntax highlighting: "RAW", "JSON", or "XML"
@classmethod
def default_headers(cls) -> Dict[str, str]:
"""Return default headers for new requests."""
# Use only major.minor version (without patch number)
version_parts = constants.VERSION.split(".")
short_version = ".".join(version_parts[:2])
return {
"User-Agent": f"Roster/{short_version}"
}
def to_dict(self): def to_dict(self):
"""Convert to dictionary for JSON serialization.""" """Convert to dictionary for JSON serialization."""
return asdict(self) return asdict(self)
@ -63,7 +51,6 @@ class HttpResponse:
headers: str # Raw header text from libsoup3 headers: str # Raw header text from libsoup3
body: str # Raw body text body: str # Raw body text
response_time_ms: float response_time_ms: float
response_size_bytes: int = 0 # Total response size in bytes
def to_dict(self): def to_dict(self):
"""Convert to dictionary for JSON serialization.""" """Convert to dictionary for JSON serialization."""
@ -82,36 +69,24 @@ class HistoryEntry:
request: HttpRequest request: HttpRequest
response: Optional[HttpResponse] response: Optional[HttpResponse]
error: Optional[str] # Error message if request failed error: Optional[str] # Error message if request failed
id: str = None # Unique identifier for the entry
has_redacted_variables: bool = False # True if sensitive variables were redacted
def __post_init__(self):
"""Generate UUID if id not provided."""
if self.id is None:
import uuid
self.id = str(uuid.uuid4())
def to_dict(self): def to_dict(self):
"""Convert to dictionary for JSON serialization.""" """Convert to dictionary for JSON serialization."""
return { return {
'id': self.id,
'timestamp': self.timestamp, 'timestamp': self.timestamp,
'request': self.request.to_dict(), 'request': self.request.to_dict(),
'response': self.response.to_dict() if self.response else None, 'response': self.response.to_dict() if self.response else None,
'error': self.error, 'error': self.error
'has_redacted_variables': self.has_redacted_variables
} }
@classmethod @classmethod
def from_dict(cls, data): def from_dict(cls, data):
"""Create instance from dictionary.""" """Create instance from dictionary."""
return cls( return cls(
id=data.get('id'), # Backwards compatible - will generate if missing
timestamp=data['timestamp'], timestamp=data['timestamp'],
request=HttpRequest.from_dict(data['request']), request=HttpRequest.from_dict(data['request']),
response=HttpResponse.from_dict(data['response']) if data.get('response') else None, response=HttpResponse.from_dict(data['response']) if data.get('response') else None,
error=data.get('error'), error=data.get('error')
has_redacted_variables=data.get('has_redacted_variables', False)
) )
@ -187,7 +162,6 @@ class Project:
icon: str = "folder-symbolic" # Icon name icon: str = "folder-symbolic" # Icon name
variable_names: List[str] = None # List of variable names defined for this project variable_names: List[str] = None # List of variable names defined for this project
environments: List[Environment] = None # List of environments environments: List[Environment] = None # List of environments
sensitive_variables: List[str] = None # List of variable names marked as sensitive (stored in keyring)
def __post_init__(self): def __post_init__(self):
"""Initialize optional fields.""" """Initialize optional fields."""
@ -195,8 +169,6 @@ class Project:
self.variable_names = [] self.variable_names = []
if self.environments is None: if self.environments is None:
self.environments = [] self.environments = []
if self.sensitive_variables is None:
self.sensitive_variables = []
def to_dict(self): def to_dict(self):
"""Convert to dictionary for JSON serialization.""" """Convert to dictionary for JSON serialization."""
@ -207,8 +179,7 @@ class Project:
'created_at': self.created_at, 'created_at': self.created_at,
'icon': self.icon, 'icon': self.icon,
'variable_names': self.variable_names, 'variable_names': self.variable_names,
'environments': [env.to_dict() for env in self.environments], 'environments': [env.to_dict() for env in self.environments]
'sensitive_variables': self.sensitive_variables
} }
@classmethod @classmethod
@ -221,8 +192,7 @@ class Project:
created_at=data['created_at'], created_at=data['created_at'],
icon=data.get('icon', 'folder-symbolic'), # Default for old data icon=data.get('icon', 'folder-symbolic'), # Default for old data
variable_names=data.get('variable_names', []), variable_names=data.get('variable_names', []),
environments=[Environment.from_dict(e) for e in data.get('environments', [])], environments=[Environment.from_dict(e) for e in data.get('environments', [])]
sensitive_variables=data.get('sensitive_variables', []) # Default for old data
) )

View File

@ -7,7 +7,6 @@
# 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
@ -21,7 +20,7 @@
from gi.repository import Adw, Gtk, Gio, GObject from gi.repository import Adw, Gtk, Gio, GObject
@Gtk.Template(resource_path='/cz/bugsy/roster/preferences-dialog.ui') @Gtk.Template(resource_path='/cz/vesp/roster/preferences-dialog.ui')
class PreferencesDialog(Adw.PreferencesWindow): class PreferencesDialog(Adw.PreferencesWindow):
__gtype_name__ = 'PreferencesDialog' __gtype_name__ = 'PreferencesDialog'
@ -39,7 +38,7 @@ class PreferencesDialog(Adw.PreferencesWindow):
self.history_manager = history_manager self.history_manager = history_manager
# Get settings # Get settings
self.settings = Gio.Settings.new('cz.bugsy.roster') self.settings = Gio.Settings.new('cz.vesp.roster')
# Bind settings to UI # Bind settings to UI
self.settings.bind( self.settings.bind(

View File

@ -7,7 +7,6 @@
# 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
@ -20,17 +19,13 @@
import json import json
import uuid import uuid
import logging
from pathlib import Path from pathlib import Path
from typing import List, Optional, Callable from typing import List, Optional
from datetime import datetime, timezone from datetime import datetime, timezone
import gi import gi
gi.require_version('GLib', '2.0') 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
logger = logging.getLogger(__name__)
class ProjectManager: class ProjectManager:
@ -38,50 +33,31 @@ class ProjectManager:
def __init__(self): def __init__(self):
# Use XDG data directory (works for both Flatpak and native) # Use XDG data directory (works for both Flatpak and native)
# Flatpak: ~/.var/app/cz.bugsy.roster/data/cz.bugsy.roster # Flatpak: ~/.var/app/cz.vesp.roster/data/cz.vesp.roster
# Native: ~/.local/share/cz.bugsy.roster # Native: ~/.local/share/cz.vesp.roster
self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.bugsy.roster' self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.vesp.roster'
self.projects_file = self.data_dir / 'requests.json' self.projects_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
def _ensure_data_dir(self): def _ensure_data_dir(self):
"""Create data directory if it doesn't exist.""" """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)
def load_projects(self, force_reload: bool = False) -> List[Project]: def load_projects(self) -> List[Project]:
""" """Load projects from JSON file."""
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:
return self._projects_cache
# Load from disk
if not self.projects_file.exists(): if not self.projects_file.exists():
self._projects_cache = []
return [] return []
try: try:
with open(self.projects_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', [])] return [Project.from_dict(p) for p in data.get('projects', [])]
return self._projects_cache
except Exception as e: except Exception as e:
logger.error(f"Error loading projects: {e}") print(f"Error loading projects: {e}")
self._projects_cache = []
return [] return []
def save_projects(self, projects: List[Project]): def save_projects(self, projects: List[Project]):
"""Save projects to JSON file and update cache.""" """Save projects to JSON file."""
try: try:
data = { data = {
'version': 1, 'version': 1,
@ -89,11 +65,8 @@ class ProjectManager:
} }
with open(self.projects_file, 'w') as f: with open(self.projects_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(f"Error saving projects: {e}") print(f"Error saving projects: {e}")
def add_project(self, name: str) -> Project: def add_project(self, name: str) -> Project:
"""Create new project with default environment.""" """Create new project with default environment."""
@ -132,23 +105,12 @@ class ProjectManager:
break break
self.save_projects(projects) self.save_projects(projects)
def delete_project(self, project_id: str, def delete_project(self, project_id: str):
callback: Optional[Callable[[], None]] = None): """Delete a project and all its requests."""
"""Delete a project and all its requests (async for secret cleanup)."""
projects = self.load_projects() projects = self.load_projects()
secret_manager = get_secret_manager()
# Remove project from list and save immediately
projects = [p for p in projects if p.id != project_id] projects = [p for p in projects if p.id != project_id]
self.save_projects(projects) self.save_projects(projects)
# Delete all secrets for this project asynchronously
def on_secrets_deleted(success):
if callback:
callback()
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.""" """Add request to a project."""
projects = self.load_projects() projects = self.load_projects()
@ -244,27 +206,15 @@ class ProjectManager:
break break
self.save_projects(projects) 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): """Delete an environment."""
"""Delete an environment (async for secret cleanup)."""
projects = self.load_projects() projects = self.load_projects()
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]
break break
self.save_projects(projects) self.save_projects(projects)
# Delete all secrets for this environment asynchronously
def on_secrets_deleted(success):
if callback:
callback()
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.""" """Add a variable to the project and all environments."""
projects = self.load_projects() projects = self.load_projects()
@ -278,81 +228,36 @@ class ProjectManager:
break break
self.save_projects(projects) 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): """Rename a variable in the project and all environments."""
"""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()
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 # 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:
is_sensitive = True
idx_sensitive = p.sensitive_variables.index(old_name)
p.sensitive_variables[idx_sensitive] = new_name
# Update all environments # 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)
break break
self.save_projects(projects) self.save_projects(projects)
# Rename secrets in keyring if sensitive def delete_variable(self, project_id: str, variable_name: str):
if is_sensitive: """Delete a variable from the project and all environments."""
secret_manager.rename_variable_secrets(project_id, old_name, new_name, lambda success: callback() if callback else None)
elif callback:
callback()
def delete_variable(self, project_id: str, variable_name: str,
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()
is_sensitive = False
environments_to_cleanup = []
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
# Remove from variable_names # 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:
is_sensitive = True
p.sensitive_variables.remove(variable_name)
environments_to_cleanup = [env.id for env in p.environments]
# Remove from all 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)
break break
self.save_projects(projects) self.save_projects(projects)
# Delete secrets for this variable asynchronously
if is_sensitive and environments_to_cleanup:
pending_count = [len(environments_to_cleanup)]
def on_single_delete(success):
pending_count[0] -= 1
if pending_count[0] == 0 and callback:
callback()
for env_id in environments_to_cleanup:
secret_manager.delete_secret(project_id, env_id, variable_name, on_single_delete)
elif 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.""" """Update a single variable value in an environment."""
projects = self.load_projects() projects = self.load_projects()
@ -385,297 +290,3 @@ class ProjectManager:
break break
break break
self.save_projects(projects) self.save_projects(projects)
# ========== Sensitive Variable Management ==========
def mark_variable_as_sensitive(self, project_id: str, variable_name: str,
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()
secret_manager = get_secret_manager()
secrets_to_store = []
for p in projects:
if p.id == project_id:
# Add to sensitive list if not already there
if variable_name not in p.sensitive_variables:
p.sensitive_variables.append(variable_name)
# Collect all environment values to store in keyring
for env in p.environments:
if variable_name in env.variables:
value = env.variables[variable_name]
secrets_to_store.append({
'environment_id': env.id,
'environment_name': env.name,
'value': value,
'project_name': p.name
})
# Clear from JSON (keep empty placeholder)
env.variables[variable_name] = ""
self.save_projects(projects)
# Store all secrets asynchronously
if not secrets_to_store:
if callback:
callback(True)
return
pending_count = [len(secrets_to_store)]
all_success = [True]
def on_single_store(success):
if not success:
all_success[0] = False
pending_count[0] -= 1
if pending_count[0] == 0 and callback:
callback(all_success[0])
for secret_info in secrets_to_store:
secret_manager.store_secret(
project_id=project_id,
environment_id=secret_info['environment_id'],
variable_name=variable_name,
value=secret_info['value'],
project_name=secret_info['project_name'],
environment_name=secret_info['environment_name'],
callback=on_single_store
)
return
if callback:
callback(False)
def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str,
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()
secret_manager = get_secret_manager()
target_project = None
environments_to_migrate = []
for p in projects:
if p.id == project_id:
target_project = p
# Remove from sensitive list
if variable_name in p.sensitive_variables:
p.sensitive_variables.remove(variable_name)
environments_to_migrate = list(p.environments)
break
if not target_project or not environments_to_migrate:
if callback:
callback(False)
return
# Retrieve all secrets, then update JSON, then delete from keyring
pending_count = [len(environments_to_migrate)]
all_success = [True]
def on_single_migrate(env):
def on_retrieve(value):
# Store in JSON
env.variables[variable_name] = value or ""
self.save_projects(projects)
# Delete from keyring
def on_delete(success):
if not success:
all_success[0] = False
pending_count[0] -= 1
if pending_count[0] == 0 and callback:
callback(all_success[0])
secret_manager.delete_secret(
project_id=project_id,
environment_id=env.id,
variable_name=variable_name,
callback=on_delete
)
return on_retrieve
for env in environments_to_migrate:
secret_manager.retrieve_secret(
project_id=project_id,
environment_id=env.id,
variable_name=variable_name,
callback=on_single_migrate(env)
)
def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool:
"""Check if a variable is marked as sensitive."""
projects = self.load_projects()
for p in projects:
if p.id == project_id:
return variable_name in p.sensitive_variables
return False
def get_variable_value(self, project_id: str, env_id: str, variable_name: str,
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()
for p in projects:
if p.id == project_id:
# Check if sensitive
if variable_name in p.sensitive_variables:
# Retrieve from keyring asynchronously
secret_manager = get_secret_manager()
def on_retrieve(value):
callback(value or "")
secret_manager.retrieve_secret(
project_id=project_id,
environment_id=env_id,
variable_name=variable_name,
callback=on_retrieve
)
return
else:
# Retrieve from JSON synchronously
for env in p.environments:
if env.id == env_id:
callback(env.variables.get(variable_name, ""))
return
callback("")
def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str,
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()
secret_manager = get_secret_manager()
for p in projects:
if p.id == project_id:
# Check if sensitive
if variable_name in p.sensitive_variables:
# Store in keyring asynchronously
for env in p.environments:
if env.id == env_id:
secret_manager.store_secret(
project_id=project_id,
environment_id=env.id,
variable_name=variable_name,
value=value,
project_name=p.name,
environment_name=env.name,
callback=callback
)
# Keep empty placeholder in JSON
env.variables[variable_name] = ""
self.save_projects(projects)
return
else:
# Store in JSON synchronously
for env in p.environments:
if env.id == env_id:
env.variables[variable_name] = value
break
self.save_projects(projects)
if callback:
callback(True)
return
if callback:
callback(False)
def get_environment_with_secrets(self, project_id: str, env_id: str,
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()
secret_manager = get_secret_manager()
for p in projects:
if p.id == project_id:
for env in p.environments:
if env.id == env_id:
# Create a copy of the environment
complete_env = Environment(
id=env.id,
name=env.name,
variables=env.variables.copy(),
created_at=env.created_at
)
# If no sensitive variables, return immediately
if not p.sensitive_variables:
callback(complete_env)
return
# Fill in sensitive variable values from keyring
def on_secrets_retrieved(secrets_dict):
for var_name, value in secrets_dict.items():
if value is not None:
complete_env.variables[var_name] = value
callback(complete_env)
secret_manager.retrieve_multiple_secrets(
project_id=project_id,
environment_id=env_id,
variable_names=list(p.sensitive_variables),
callback=on_secrets_retrieved
)
return
callback(None)

View File

@ -7,7 +7,6 @@
# 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
@ -21,25 +20,11 @@
import gi import gi
gi.require_version('GtkSource', '5') gi.require_version('GtkSource', '5')
from gi.repository import Adw, Gtk, GLib, GtkSource, GObject from gi.repository import Adw, Gtk, GLib, GtkSource, GObject
from typing import Optional, Set, Dict, List
import logging
from .models import HttpRequest, HttpResponse from .models import HttpRequest, HttpResponse
from .widgets.header_row import HeaderRow from .widgets.header_row import HeaderRow
from .constants import (
UI_PANE_REQUEST_RESPONSE_POSITION,
UI_PANE_RESPONSE_DETAILS_POSITION,
UI_PANE_RESULTS_PANEL_POSITION,
UI_PANE_SCRIPTS_POSITION,
UI_SCRIPT_RESULTS_PANEL_HEIGHT,
DEBOUNCE_VARIABLE_INDICATORS_MS,
DISPLAY_VARIABLE_MAX_LENGTH,
DISPLAY_VARIABLE_TRUNCATE_SUFFIX,
)
import json import json
import xml.dom.minidom import xml.dom.minidom
logger = logging.getLogger(__name__)
class RequestTabWidget(Gtk.Box): class RequestTabWidget(Gtk.Box):
"""Widget representing a single request tab's UI.""" """Widget representing a single request tab's UI."""
@ -47,21 +32,20 @@ class RequestTabWidget(Gtk.Box):
def __init__(self, tab_id, request=None, response=None, project_id=None, selected_environment_id=None, scripts=None, **kwargs): def __init__(self, tab_id, request=None, response=None, project_id=None, selected_environment_id=None, scripts=None, **kwargs):
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
self.tab_id: str = tab_id self.tab_id = tab_id
self.request: HttpRequest = request or HttpRequest(method="GET", url="", headers=HttpRequest.default_headers(), body="", syntax="RAW") self.request = request or HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW")
self.response: Optional[HttpResponse] = response self.response = response
self.modified: bool = False self.modified = False
self.original_request: Optional[HttpRequest] = None self.original_request = None
self.original_scripts = None self.original_scripts = None
self.project_id: Optional[str] = project_id self.project_id = project_id
self.selected_environment_id: Optional[str] = selected_environment_id self.selected_environment_id = selected_environment_id
self.scripts = scripts self.scripts = scripts
self.project_manager = None # Will be injected from window self.project_manager = None # Will be injected from window
self.environment_dropdown: Optional[Gtk.DropDown] = None self.environment_dropdown = None # Will be created if project_id is set
self.env_separator: Optional[Gtk.Separator] = None self.env_separator = None # Visual separator after environment dropdown
self.undefined_variables: Set[str] = set() self.undefined_variables = set() # Track undefined variables for visual feedback
self._update_indicators_timeout_id: Optional[int] = None self._update_indicators_timeout_id = None # Debounce timer for indicator updates
self._is_programmatically_changing_environment: bool = False
# Build the UI # Build the UI
self._build_ui() self._build_ui()
@ -77,7 +61,7 @@ class RequestTabWidget(Gtk.Box):
# Setup change tracking # Setup change tracking
self._setup_change_tracking() self._setup_change_tracking()
def _build_ui(self) -> None: def _build_ui(self):
"""Build the complete UI for this tab.""" """Build the complete UI for this tab."""
# URL Input Section - outer container (store as instance var for dynamic updates) # URL Input Section - outer container (store as instance var for dynamic updates)
@ -130,7 +114,7 @@ class RequestTabWidget(Gtk.Box):
# Horizontal Split: Request | Response # Horizontal Split: Request | Response
split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) split_pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
split_pane.set_vexpand(True) split_pane.set_vexpand(True)
split_pane.set_position(UI_PANE_REQUEST_RESPONSE_POSITION) split_pane.set_position(600)
split_pane.set_shrink_start_child(False) split_pane.set_shrink_start_child(False)
split_pane.set_shrink_end_child(False) split_pane.set_shrink_end_child(False)
split_pane.set_resize_start_child(True) split_pane.set_resize_start_child(True)
@ -165,32 +149,19 @@ class RequestTabWidget(Gtk.Box):
# Response Panel # Response Panel
response_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) response_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Stack switcher at the top
response_switcher = Gtk.StackSwitcher()
response_switcher.set_halign(Gtk.Align.CENTER)
response_switcher.set_margin_top(8)
response_switcher.set_margin_bottom(8)
response_box.append(response_switcher)
# Create a vertical paned for response stack and result panels
self.response_main_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
self.response_main_paned.set_vexpand(True)
self.response_main_paned.set_position(UI_PANE_RESPONSE_DETAILS_POSITION)
self.response_main_paned.set_shrink_start_child(False)
# Don't allow results panels to shrink below their minimum size
self.response_main_paned.set_shrink_end_child(False)
self.response_main_paned.set_resize_start_child(True)
self.response_main_paned.set_resize_end_child(True)
# Response Stack
self.response_stack = Gtk.Stack() self.response_stack = 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)
self.response_stack.set_transition_duration(150) self.response_stack.set_transition_duration(150)
response_switcher = Gtk.StackSwitcher()
response_switcher.set_stack(self.response_stack) response_switcher.set_stack(self.response_stack)
self.response_main_paned.set_start_child(self.response_stack) response_switcher.set_halign(Gtk.Align.CENTER)
response_switcher.set_margin_top(8)
response_switcher.set_margin_bottom(8)
response_box.append(response_switcher)
response_box.append(self.response_stack)
# Response headers # Response headers
headers_scroll = Gtk.ScrolledWindow() headers_scroll = Gtk.ScrolledWindow()
@ -237,74 +208,14 @@ 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 # Script Results Panel (collapsible)
self.results_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
self.results_paned.set_vexpand(True)
self.results_paned.set_position(UI_PANE_RESULTS_PANEL_POSITION)
self.results_paned.set_visible(False) # Initially hidden until scripts produce output
# Don't allow children to shrink below their minimum size
self.results_paned.set_shrink_start_child(False)
self.results_paned.set_shrink_end_child(False)
self.results_paned.set_resize_start_child(True)
self.results_paned.set_resize_end_child(True)
# Preprocessing Results Panel (resizable)
self.preprocessing_results_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.preprocessing_results_container.set_visible(False) # Initially hidden
# Set minimum height to ensure header is always visible (header ~40px + content min 60px)
self.preprocessing_results_container.set_size_request(-1, 100)
# Header
preprocessing_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
preprocessing_header.set_margin_start(12)
preprocessing_header.set_margin_end(12)
preprocessing_header.set_margin_top(6)
preprocessing_header.set_margin_bottom(6)
preprocessing_label = Gtk.Label(label="Preprocessing Results")
preprocessing_label.add_css_class("heading")
preprocessing_label.set_halign(Gtk.Align.START)
preprocessing_header.append(preprocessing_label)
# Spacer
preprocessing_spacer = Gtk.Box()
preprocessing_spacer.set_hexpand(True)
preprocessing_header.append(preprocessing_spacer)
# Status icon
self.preprocessing_status_icon = Gtk.Image()
self.preprocessing_status_icon.set_from_icon_name("object-select-symbolic")
preprocessing_header.append(self.preprocessing_status_icon)
self.preprocessing_results_container.append(preprocessing_header)
# Output text view (scrollable, resizable)
self.preprocessing_output_scroll = Gtk.ScrolledWindow()
self.preprocessing_output_scroll.set_vexpand(True)
self.preprocessing_output_scroll.set_min_content_height(60)
self.preprocessing_output_scroll.set_margin_start(12)
self.preprocessing_output_scroll.set_margin_end(12)
self.preprocessing_output_scroll.set_margin_bottom(6)
self.preprocessing_output_textview = Gtk.TextView()
self.preprocessing_output_textview.set_editable(False)
self.preprocessing_output_textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
self.preprocessing_output_textview.set_monospace(True)
self.preprocessing_output_textview.set_left_margin(6)
self.preprocessing_output_textview.set_right_margin(6)
self.preprocessing_output_textview.set_top_margin(6)
self.preprocessing_output_textview.set_bottom_margin(6)
self.preprocessing_output_scroll.set_child(self.preprocessing_output_textview)
self.preprocessing_results_container.append(self.preprocessing_output_scroll)
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) # Initially hidden 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) # Separator
results_separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
results_separator.set_margin_top(6)
self.script_results_container.append(results_separator)
# Header # Header
results_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) results_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
@ -325,15 +236,15 @@ class RequestTabWidget(Gtk.Box):
# Status icon # 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("emblem-ok-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) # Output text view (scrollable)
self.script_output_scroll = Gtk.ScrolledWindow() self.script_output_scroll = Gtk.ScrolledWindow()
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)
self.script_output_scroll.set_max_content_height(60)
self.script_output_scroll.set_margin_start(12) self.script_output_scroll.set_margin_start(12)
self.script_output_scroll.set_margin_end(12) self.script_output_scroll.set_margin_end(12)
self.script_output_scroll.set_margin_bottom(6) self.script_output_scroll.set_margin_bottom(6)
@ -350,13 +261,37 @@ class RequestTabWidget(Gtk.Box):
self.script_output_scroll.set_child(self.script_output_textview) self.script_output_scroll.set_child(self.script_output_textview)
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) # Expander control (separator-based, shown when output > 3 lines)
self.script_expander = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self.script_expander.set_margin_start(12)
self.script_expander.set_margin_end(12)
self.script_expander.set_margin_bottom(6)
self.script_expander.set_visible(False) # Hidden by default
# Set the results paned as the end child of the main response paned expander_sep1 = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
self.response_main_paned.set_end_child(self.results_paned) expander_sep1.set_hexpand(True)
self.script_expander.append(expander_sep1)
# Add the main paned to response_box self.script_expander_label = Gtk.Label(label="Show full output")
response_box.append(self.response_main_paned) self.script_expander_label.add_css_class("dim-label")
self.script_expander_label.add_css_class("caption")
self.script_expander.append(self.script_expander_label)
expander_sep2 = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
expander_sep2.set_hexpand(True)
self.script_expander.append(expander_sep2)
# Add click gesture for expander
expander_gesture = Gtk.GestureClick()
expander_gesture.connect("released", self._on_script_expander_clicked)
self.script_expander.add_controller(expander_gesture)
self.script_results_container.append(self.script_expander)
# Track expansion state
self.script_output_expanded = False
response_box.append(self.script_results_container)
# Status Bar # Status Bar
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
@ -372,9 +307,6 @@ class RequestTabWidget(Gtk.Box):
self.time_label = Gtk.Label(label="") self.time_label = Gtk.Label(label="")
status_box.append(self.time_label) status_box.append(self.time_label)
self.size_label = Gtk.Label(label="")
status_box.append(self.size_label)
# Spacer # Spacer
spacer = Gtk.Box() spacer = Gtk.Box()
spacer.set_hexpand(True) spacer.set_hexpand(True)
@ -386,7 +318,7 @@ class RequestTabWidget(Gtk.Box):
self.append(split_pane) self.append(split_pane)
def _build_headers_tab(self) -> None: def _build_headers_tab(self):
"""Build the headers tab.""" """Build the headers tab."""
headers_scroll = Gtk.ScrolledWindow() headers_scroll = Gtk.ScrolledWindow()
headers_scroll.set_vexpand(True) headers_scroll.set_vexpand(True)
@ -412,7 +344,7 @@ class RequestTabWidget(Gtk.Box):
# Add initial empty header row # Add initial empty header row
self._add_header_row() self._add_header_row()
def _build_body_tab(self) -> None: def _build_body_tab(self):
"""Build the body tab.""" """Build the body tab."""
body_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) body_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
@ -490,29 +422,30 @@ class RequestTabWidget(Gtk.Box):
# Vertical paned to separate preprocessing and postprocessing # Vertical paned to separate preprocessing and postprocessing
paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
paned.set_position(UI_PANE_SCRIPTS_POSITION) paned.set_position(300)
paned.set_vexpand(True) paned.set_vexpand(True)
# Preprocessing Section # Preprocessing Section (disabled for now)
preprocessing_section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) preprocessing_section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
preprocessing_section.set_margin_start(12) preprocessing_section.set_margin_start(12)
preprocessing_section.set_margin_end(12) preprocessing_section.set_margin_end(12)
preprocessing_section.set_margin_top(12) preprocessing_section.set_margin_top(12)
preprocessing_section.set_margin_bottom(12) preprocessing_section.set_margin_bottom(12)
preprocessing_label = Gtk.Label(label="Preprocessing") preprocessing_label = Gtk.Label(label="Preprocessing (Coming Soon)")
preprocessing_label.set_halign(Gtk.Align.START) preprocessing_label.set_halign(Gtk.Align.START)
preprocessing_label.add_css_class("heading") preprocessing_label.add_css_class("heading")
preprocessing_label.add_css_class("dim-label")
preprocessing_section.append(preprocessing_label) preprocessing_section.append(preprocessing_label)
preprocessing_scroll = Gtk.ScrolledWindow() preprocessing_scroll = Gtk.ScrolledWindow()
preprocessing_scroll.set_vexpand(True) preprocessing_scroll.set_vexpand(True)
self.preprocessing_sourceview = GtkSource.View() self.preprocessing_sourceview = GtkSource.View()
self.preprocessing_sourceview.set_editable(True) self.preprocessing_sourceview.set_editable(False)
self.preprocessing_sourceview.set_sensitive(True) self.preprocessing_sourceview.set_sensitive(False)
self.preprocessing_sourceview.set_show_line_numbers(True) self.preprocessing_sourceview.set_show_line_numbers(True)
self.preprocessing_sourceview.set_highlight_current_line(True) self.preprocessing_sourceview.set_highlight_current_line(False)
self.preprocessing_sourceview.set_left_margin(12) self.preprocessing_sourceview.set_left_margin(12)
self.preprocessing_sourceview.set_right_margin(12) self.preprocessing_sourceview.set_right_margin(12)
self.preprocessing_sourceview.set_top_margin(12) self.preprocessing_sourceview.set_top_margin(12)
@ -538,29 +471,15 @@ class RequestTabWidget(Gtk.Box):
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
) )
# Add placeholder text
preprocessing_buffer = self.preprocessing_sourceview.get_buffer()
placeholder_text = """// Available: request object (modifiable)
// - request.method: "GET", "POST", "PUT", "DELETE"
// - request.url: Full URL string
// - request.headers: Object with header key-value pairs
// - request.body: Request body string
//
// Available: roster API
// - roster.getVariable(name): Get variable from selected environment
// - roster.setVariable(name, value): Set/update variable
// - roster.setVariables({key: value}): Batch set
// - roster.project.name: Current project name
// - roster.project.environments: Array of environment names
//
// Modify the request before sending:
// request.headers['Authorization'] = 'Bearer ' + roster.getVariable('token');
"""
preprocessing_buffer.set_text(placeholder_text)
preprocessing_scroll.set_child(self.preprocessing_sourceview) preprocessing_scroll.set_child(self.preprocessing_sourceview)
preprocessing_section.append(preprocessing_scroll) preprocessing_section.append(preprocessing_scroll)
info_label = Gtk.Label(label="Preprocessing will be available in a future release")
info_label.set_halign(Gtk.Align.START)
info_label.add_css_class("dim-label")
info_label.add_css_class("caption")
preprocessing_section.append(info_label)
paned.set_start_child(preprocessing_section) paned.set_start_child(preprocessing_section)
# Postprocessing Section (functional) # Postprocessing Section (functional)
@ -651,7 +570,6 @@ class RequestTabWidget(Gtk.Box):
scheme = scheme_manager.get_scheme('Adwaita-dark' if is_dark else 'Adwaita') scheme = scheme_manager.get_scheme('Adwaita-dark' if is_dark else 'Adwaita')
if scheme: if scheme:
self.preprocessing_sourceview.get_buffer().set_style_scheme(scheme)
self.postprocessing_sourceview.get_buffer().set_style_scheme(scheme) self.postprocessing_sourceview.get_buffer().set_style_scheme(scheme)
def _on_script_changed(self, buffer): def _on_script_changed(self, buffer):
@ -726,7 +644,7 @@ class RequestTabWidget(Gtk.Box):
self.headers_listbox.remove(parent) self.headers_listbox.remove(parent)
self._on_request_changed(None) self._on_request_changed(None)
def _setup_change_tracking(self) -> None: def _setup_change_tracking(self):
"""Setup change tracking for this tab.""" """Setup change tracking for this tab."""
self.url_entry.connect("changed", self._on_request_changed) self.url_entry.connect("changed", self._on_request_changed)
self.method_dropdown.connect("notify::selected", self._on_request_changed) self.method_dropdown.connect("notify::selected", self._on_request_changed)
@ -736,9 +654,6 @@ class RequestTabWidget(Gtk.Box):
body_buffer.connect("changed", self._on_request_changed) body_buffer.connect("changed", self._on_request_changed)
# Setup script change tracking # Setup script change tracking
preprocessing_buffer = self.preprocessing_sourceview.get_buffer()
preprocessing_buffer.connect("changed", self._on_request_changed)
postprocessing_buffer = self.postprocessing_sourceview.get_buffer() postprocessing_buffer = self.postprocessing_sourceview.get_buffer()
postprocessing_buffer.connect("changed", self._on_request_changed) postprocessing_buffer.connect("changed", self._on_request_changed)
@ -765,7 +680,7 @@ class RequestTabWidget(Gtk.Box):
'modified-changed': (GObject.SignalFlags.RUN_FIRST, None, (bool,)) 'modified-changed': (GObject.SignalFlags.RUN_FIRST, None, (bool,))
} }
def _is_different_from_original(self, request: HttpRequest) -> bool: def _is_different_from_original(self, request):
"""Check if current request differs from original.""" """Check if current request differs from original."""
if not self.original_request: if not self.original_request:
return False return False
@ -796,7 +711,7 @@ class RequestTabWidget(Gtk.Box):
return request_changed or scripts_changed return request_changed or scripts_changed
def _load_request(self, request: HttpRequest) -> None: def _load_request(self, request):
"""Load a request into this tab's UI.""" """Load a request into this tab's UI."""
# Set method # Set method
methods = ["GET", "POST", "PUT", "DELETE"] methods = ["GET", "POST", "PUT", "DELETE"]
@ -825,11 +740,7 @@ class RequestTabWidget(Gtk.Box):
if request.syntax in syntax_options: if request.syntax in syntax_options:
self.body_language_dropdown.set_selected(syntax_options.index(request.syntax)) self.body_language_dropdown.set_selected(syntax_options.index(request.syntax))
# Update variable indicators after loading the request (if project is set) def get_request(self):
if self.project_id:
self._update_variable_indicators()
def get_request(self) -> HttpRequest:
"""Build and return HttpRequest from current UI state.""" """Build and return HttpRequest from current UI state."""
method = self.method_dropdown.get_selected_item().get_string() method = self.method_dropdown.get_selected_item().get_string()
url = self.url_entry.get_text().strip() url = self.url_entry.get_text().strip()
@ -892,7 +803,7 @@ class RequestTabWidget(Gtk.Box):
postprocessing_buffer = self.postprocessing_sourceview.get_buffer() postprocessing_buffer = self.postprocessing_sourceview.get_buffer()
postprocessing_buffer.set_text(scripts.postprocessing) postprocessing_buffer.set_text(scripts.postprocessing)
def display_response(self, response: HttpResponse) -> None: def display_response(self, response):
"""Display response in this tab's UI.""" """Display response in this tab's UI."""
self.response = response self.response = response
@ -904,10 +815,6 @@ class RequestTabWidget(Gtk.Box):
time_text = f"{response.response_time_ms:.0f} ms" time_text = f"{response.response_time_ms:.0f} ms"
self.time_label.set_text(time_text) self.time_label.set_text(time_text)
# Update size
size_text = self._format_size(response.response_size_bytes)
self.size_label.set_text(size_text)
# Update response headers # Update response headers
buffer = self.response_headers_textview.get_buffer() buffer = self.response_headers_textview.get_buffer()
buffer.set_text(response.headers) buffer.set_text(response.headers)
@ -924,11 +831,10 @@ class RequestTabWidget(Gtk.Box):
language = self._get_language_from_content_type(content_type) language = self._get_language_from_content_type(content_type)
source_buffer.set_language(language) source_buffer.set_language(language)
def display_error(self, error: str) -> None: def display_error(self, error):
"""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")
self.time_label.set_text("") self.time_label.set_text("")
self.size_label.set_text("")
# Clear response headers # Clear response headers
buffer = self.response_headers_textview.get_buffer() buffer = self.response_headers_textview.get_buffer()
@ -946,22 +852,12 @@ class RequestTabWidget(Gtk.Box):
if not isinstance(script_result, ScriptResult): if not isinstance(script_result, ScriptResult):
return return
# Determine if there's meaningful content to display (before building output_text) # Show container
has_content = ( self.script_results_container.set_visible(True)
(script_result.success and script_result.output.strip()) or # Has console output
script_result.variable_updates or # Set variables
script_result.warnings or # Has warnings
not script_result.success # Has errors
)
# If no content, hide the panel and return early # Set status icon
if not has_content:
self._clear_script_results()
return
# Build output text for display
if script_result.success: if script_result.success:
self.script_status_icon.set_from_icon_name("object-select-symbolic") self.script_status_icon.set_from_icon_name("emblem-ok-symbolic")
output_text = script_result.output output_text = script_result.output
else: else:
self.script_status_icon.set_from_icon_name("dialog-error-symbolic") self.script_status_icon.set_from_icon_name("dialog-error-symbolic")
@ -974,7 +870,7 @@ class RequestTabWidget(Gtk.Box):
output_text += "\n--- Variables Set ---" output_text += "\n--- Variables Set ---"
for var_name, var_value in script_result.variable_updates.items(): for var_name, var_value in script_result.variable_updates.items():
# Truncate long values for display # Truncate long values for display
display_value = var_value if len(var_value) <= DISPLAY_VARIABLE_MAX_LENGTH else var_value[:DISPLAY_VARIABLE_MAX_LENGTH - 3] + DISPLAY_VARIABLE_TRUNCATE_SUFFIX display_value = var_value if len(var_value) <= 50 else var_value[:47] + "..."
output_text += f"\n>>> {var_name} = '{display_value}'" output_text += f"\n>>> {var_name} = '{display_value}'"
# Append warnings to output # Append warnings to output
@ -985,158 +881,48 @@ class RequestTabWidget(Gtk.Box):
for warning in script_result.warnings: for warning in script_result.warnings:
output_text += f"\n{warning}" output_text += f"\n{warning}"
# Show container and parent paned
self.results_paned.set_visible(True)
self.script_results_container.set_visible(True)
# Adjust paned positions to make the panel visible (use idle_add to wait for proper sizing)
GLib.idle_add(self._adjust_paned_for_script_results)
# Set output text # Set output text
buffer = self.script_output_textview.get_buffer() buffer = self.script_output_textview.get_buffer()
buffer.set_text(output_text) buffer.set_text(output_text)
# Determine if expander is needed
num_lines = output_text.count('\n') + 1
needs_expander = num_lines > 3 or len(output_text) > 150
if needs_expander:
self.script_expander.set_visible(True)
self.script_expander.set_cursor_from_name("pointer")
else:
self.script_expander.set_visible(False)
# Reset expansion state
self.script_output_expanded = False
self.script_output_scroll.set_min_content_height(60)
self.script_output_scroll.set_max_content_height(60)
self.script_expander_label.set_text("Show full output")
def _clear_script_results(self): def _clear_script_results(self):
"""Clear and hide script results panel.""" """Clear and hide script results panel."""
self.script_results_container.set_visible(False) self.script_results_container.set_visible(False)
buffer = self.script_output_textview.get_buffer() buffer = self.script_output_textview.get_buffer()
buffer.set_text("") buffer.set_text("")
self.script_expander.set_visible(False)
self.script_output_expanded = False
# Update results_paned visibility and minimum size based on remaining visible panels def _on_script_expander_clicked(self, gesture, n_press, x, y):
if self.preprocessing_results_container.get_visible(): """Toggle script output expansion."""
self.results_paned.set_size_request(-1, 100) # Only preprocessing visible self.script_output_expanded = not self.script_output_expanded
if self.script_output_expanded:
# Expand: set max first to avoid assertion errors
self.script_output_scroll.set_max_content_height(300)
self.script_output_scroll.set_min_content_height(300)
self.script_expander_label.set_text("Collapse output")
else: else:
self.results_paned.set_visible(False) # Hide parent paned when both panels are hidden # Collapse: set min first to avoid assertion errors
self.results_paned.set_size_request(-1, -1) # No panels visible, no minimum self.script_output_scroll.set_min_content_height(60)
self.script_output_scroll.set_max_content_height(60)
def _adjust_paned_for_script_results(self): self.script_expander_label.set_text("Show full output")
"""Adjust paned positions to show script results panel."""
# Ensure results_paned has proper minimum size based on visible panels
if self.preprocessing_results_container.get_visible():
# Both panels visible: minimum 200px (100px each)
self.results_paned.set_size_request(-1, 200)
else:
# Only script results visible: minimum 100px
self.results_paned.set_size_request(-1, 100)
# Reserve space in the main paned for results
main_height = self.response_main_paned.get_height()
if main_height > 200:
# Set position to show ~150px of results at the bottom
self.response_main_paned.set_position(main_height - UI_SCRIPT_RESULTS_PANEL_HEIGHT)
# Adjust results paned to show script output (bottom panel)
results_height = self.results_paned.get_height()
if results_height > 150:
# If preprocessing is visible, share space
if self.preprocessing_results_container.get_visible():
self.results_paned.set_position(UI_PANE_RESULTS_PANEL_POSITION) # Give preprocessing minimal space
# If only script results, the paned will show it automatically
return False # Don't repeat
def display_preprocessing_results(self, preprocessing_result):
"""Display preprocessing execution results."""
from .script_executor import ScriptResult
if not isinstance(preprocessing_result, ScriptResult):
return
# Determine if there's meaningful content to display (before building output_text)
has_content = (
(preprocessing_result.success and preprocessing_result.output.strip()) or # Has console output
preprocessing_result.variable_updates or # Set variables
preprocessing_result.warnings or # Has warnings
not preprocessing_result.success # Has errors
)
# If no content, hide the panel and return early
if not has_content:
self._clear_preprocessing_results()
return
# Build output text for display
if preprocessing_result.success:
self.preprocessing_status_icon.set_from_icon_name("object-select-symbolic")
output_text = preprocessing_result.output
else:
self.preprocessing_status_icon.set_from_icon_name("dialog-error-symbolic")
output_text = preprocessing_result.error if preprocessing_result.error else "Unknown error"
# Append variable updates to output
if preprocessing_result.variable_updates:
if output_text:
output_text += "\n"
output_text += "\n--- Variables Set ---"
for var_name, var_value in preprocessing_result.variable_updates.items():
# Truncate long values for display
display_value = var_value if len(var_value) <= DISPLAY_VARIABLE_MAX_LENGTH else var_value[:DISPLAY_VARIABLE_MAX_LENGTH - 3] + DISPLAY_VARIABLE_TRUNCATE_SUFFIX
output_text += f"\n>>> {var_name} = '{display_value}'"
# Append warnings to output
if preprocessing_result.warnings:
if output_text:
output_text += "\n"
output_text += "\n--- Warnings ---"
for warning in preprocessing_result.warnings:
output_text += f"\n{warning}"
# Add request modification summary if successful and there's other content to show
if preprocessing_result.success and preprocessing_result.modified_request:
if output_text:
output_text += "\n"
output_text += "\n--- Request Modified ---"
output_text += "\n✓ Request was modified by preprocessing script"
# Show container and parent paned
self.results_paned.set_visible(True)
self.preprocessing_results_container.set_visible(True)
# Adjust paned positions to make the panel visible (use idle_add to wait for proper sizing)
GLib.idle_add(self._adjust_paned_for_preprocessing_results)
# Set output text
buffer = self.preprocessing_output_textview.get_buffer()
buffer.set_text(output_text)
def _clear_preprocessing_results(self):
"""Clear and hide preprocessing results panel."""
self.preprocessing_results_container.set_visible(False)
buffer = self.preprocessing_output_textview.get_buffer()
buffer.set_text("")
# Update results_paned visibility and minimum size based on remaining visible panels
if self.script_results_container.get_visible():
self.results_paned.set_size_request(-1, 100) # Only script results visible
else:
self.results_paned.set_visible(False) # Hide parent paned when both panels are hidden
self.results_paned.set_size_request(-1, -1) # No panels visible, no minimum
def _adjust_paned_for_preprocessing_results(self):
"""Adjust paned positions to show preprocessing results panel."""
# Ensure results_paned has proper minimum size based on visible panels
if self.script_results_container.get_visible():
# Both panels visible: minimum 200px (100px each)
self.results_paned.set_size_request(-1, 200)
else:
# Only preprocessing visible: minimum 100px
self.results_paned.set_size_request(-1, 100)
# Reserve space in the main paned for results
main_height = self.response_main_paned.get_height()
if main_height > 200:
# Set position to show ~150px of results at the bottom
self.response_main_paned.set_position(main_height - UI_SCRIPT_RESULTS_PANEL_HEIGHT)
# Adjust results paned to show preprocessing output (top panel)
results_height = self.results_paned.get_height()
if results_height > 150:
# If script results is also visible, share space equally
if self.script_results_container.get_visible():
self.results_paned.set_position(results_height // 2) # Split space
# If only preprocessing, the paned will show it automatically
return False # Don't repeat
def _extract_content_type(self, headers_text): def _extract_content_type(self, headers_text):
"""Extract content-type from response headers.""" """Extract content-type from response headers."""
@ -1183,24 +969,10 @@ class RequestTabWidget(Gtk.Box):
dom = xml.dom.minidom.parseString(body) dom = xml.dom.minidom.parseString(body)
return dom.toprettyxml(indent=" ") return dom.toprettyxml(indent=" ")
except Exception as e: except Exception as e:
logger.debug(f"Failed to format response body: {e}") print(f"Failed to format body: {e}")
return body return body
def _format_size(self, size_bytes):
"""Format size in bytes to human-readable format (B, KB, MB, GB)."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
size_kb = size_bytes / 1024
return f"{size_kb:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
size_mb = size_bytes / (1024 * 1024)
return f"{size_mb:.1f} MB"
else:
size_gb = size_bytes / (1024 * 1024 * 1024)
return f"{size_gb:.1f} GB"
def _build_environment_selector_inline(self, container): def _build_environment_selector_inline(self, container):
"""Build environment selector and add to container.""" """Build environment selector and add to container."""
if not self.project_id: if not self.project_id:
@ -1211,8 +983,8 @@ class RequestTabWidget(Gtk.Box):
self.environment_dropdown.set_enable_search(False) self.environment_dropdown.set_enable_search(False)
self.environment_dropdown.set_tooltip_text("Select environment for variable substitution") self.environment_dropdown.set_tooltip_text("Select environment for variable substitution")
# Connect signal handler # Connect signal
self._env_change_handler_id = self.environment_dropdown.connect("notify::selected", self._on_environment_changed) self.environment_dropdown.connect("notify::selected", self._on_environment_changed)
# Add to container at the beginning (left side) # Add to container at the beginning (left side)
container.prepend(self.environment_dropdown) container.prepend(self.environment_dropdown)
@ -1237,40 +1009,31 @@ class RequestTabWidget(Gtk.Box):
if not project: if not project:
return return
# Set flag to indicate we're programmatically changing the dropdown # Build string list with "None" + environment names
# This prevents the signal handler from triggering indicator updates during initialization string_list = Gtk.StringList()
self._is_programmatically_changing_environment = True string_list.append("None")
try: # Track environment IDs (index 0 is None)
# Build string list with "None" + environment names self.environment_ids = [None]
string_list = Gtk.StringList()
string_list.append("None")
# Track environment IDs (index 0 is None) for env in project.environments:
self.environment_ids = [None] string_list.append(env.name)
self.environment_ids.append(env.id)
for env in project.environments: self.environment_dropdown.set_model(string_list)
string_list.append(env.name)
self.environment_ids.append(env.id)
self.environment_dropdown.set_model(string_list) # Select current environment
if self.selected_environment_id:
# Select current environment try:
if self.selected_environment_id: index = self.environment_ids.index(self.selected_environment_id)
try: self.environment_dropdown.set_selected(index)
index = self.environment_ids.index(self.selected_environment_id) except ValueError:
self.environment_dropdown.set_selected(index)
except ValueError:
self.environment_dropdown.set_selected(0) # Default to "None"
else:
self.environment_dropdown.set_selected(0) # Default to "None" self.environment_dropdown.set_selected(0) # Default to "None"
else:
self.environment_dropdown.set_selected(0) # Default to "None"
finally: # Update indicators after environment is set
# Always clear the flag self._update_variable_indicators()
self._is_programmatically_changing_environment = False
# Note: Don't update indicators here as the request might not be loaded yet
# Indicators will be updated when environment changes or request is loaded
def _show_environment_selector(self): def _show_environment_selector(self):
"""Show environment selector (create if it doesn't exist).""" """Show environment selector (create if it doesn't exist)."""
@ -1294,79 +1057,65 @@ class RequestTabWidget(Gtk.Box):
# Populate if project_manager is available # Populate if project_manager is available
if self.project_manager: if self.project_manager:
self._populate_environment_dropdown() self._populate_environment_dropdown()
# Update indicators after environment selector is shown
self._update_variable_indicators()
def _on_environment_changed(self, dropdown, _param): def _on_environment_changed(self, dropdown, _param):
"""Handle environment selection change.""" """Handle environment selection change."""
# If we're programmatically changing the environment during population, skip this
if self._is_programmatically_changing_environment:
return
if not hasattr(self, 'environment_ids'): if not hasattr(self, 'environment_ids'):
logger.warning("Environment changed but environment_ids not initialized")
return return
selected_index = dropdown.get_selected() selected_index = dropdown.get_selected()
if selected_index < len(self.environment_ids): if selected_index < len(self.environment_ids):
self.selected_environment_id = self.environment_ids[selected_index] self.selected_environment_id = self.environment_ids[selected_index]
else: # Update visual indicators when environment changes
logger.warning(f"Environment index {selected_index} out of range (max {len(self.environment_ids)-1})") self._update_variable_indicators()
self.selected_environment_id = None
# Always update visual indicators when environment changes def get_selected_environment(self):
self._update_variable_indicators() """Get the currently selected environment object."""
def get_selected_environment(self, callback):
"""
Get the currently selected environment object with all values (async).
This includes sensitive variable values from the keyring.
Args:
callback: Function called with the Environment object (or None)
"""
if not self.selected_environment_id or not self.project_manager or not self.project_id: if not self.selected_environment_id or not self.project_manager or not self.project_id:
callback(None) return None
return
# Get environment with secrets (includes values from keyring) - async # Load projects
self.project_manager.get_environment_with_secrets( projects = self.project_manager.load_projects()
self.project_id, project = None
self.selected_environment_id, for p in projects:
callback if p.id == self.project_id:
) project = p
break
def _detect_undefined_variables(self, callback): if not project:
"""Detect undefined variables in the current request (async). return None
Args: # Find environment
callback: Function called with set of undefined variable names for env in project.environments:
""" if env.id == self.selected_environment_id:
return env
return None
def _detect_undefined_variables(self):
"""Detect undefined variables in the current request. Returns set of undefined variable names."""
from .variable_substitution import VariableSubstitution from .variable_substitution import VariableSubstitution
# Get current request from UI # Get current request from UI
request = self.get_request() request = self.get_request()
def on_environment_loaded(env): # Get selected environment
if not env: env = self.get_selected_environment()
# No environment selected - ALL variables are undefined
# Find all variables in the request
all_vars = set()
all_vars.update(VariableSubstitution.find_variables(request.url))
all_vars.update(VariableSubstitution.find_variables(request.body))
for key, value in request.headers.items():
all_vars.update(VariableSubstitution.find_variables(key))
all_vars.update(VariableSubstitution.find_variables(value))
callback(all_vars)
else:
# Environment selected - find which variables are undefined
_, undefined = VariableSubstitution.substitute_request(request, env)
callback(undefined)
# Get selected environment asynchronously if not env:
self.get_selected_environment(on_environment_loaded) # No environment selected - ALL variables are undefined
# Find all variables in the request
all_vars = set()
all_vars.update(VariableSubstitution.find_variables(request.url))
all_vars.update(VariableSubstitution.find_variables(request.body))
for key, value in request.headers.items():
all_vars.update(VariableSubstitution.find_variables(key))
all_vars.update(VariableSubstitution.find_variables(value))
return all_vars
else:
# Environment selected - find which variables are undefined
_, undefined = VariableSubstitution.substitute_request(request, env)
return undefined
def _schedule_indicator_update(self): def _schedule_indicator_update(self):
"""Schedule an indicator update with debouncing.""" """Schedule an indicator update with debouncing."""
@ -1375,7 +1124,7 @@ class RequestTabWidget(Gtk.Box):
GLib.source_remove(self._update_indicators_timeout_id) GLib.source_remove(self._update_indicators_timeout_id)
# Schedule new update after 500ms # Schedule new update after 500ms
self._update_indicators_timeout_id = GLib.timeout_add(DEBOUNCE_VARIABLE_INDICATORS_MS, self._update_variable_indicators_timeout) self._update_indicators_timeout_id = GLib.timeout_add(500, self._update_variable_indicators_timeout)
def _update_variable_indicators_timeout(self): def _update_variable_indicators_timeout(self):
"""Timeout callback for updating indicators.""" """Timeout callback for updating indicators."""
@ -1384,26 +1133,18 @@ class RequestTabWidget(Gtk.Box):
return False # Don't repeat return False # Don't repeat
def _update_variable_indicators(self): def _update_variable_indicators(self):
"""Update visual indicators for undefined variables (async).""" """Update visual indicators for undefined variables."""
# Only update if we have a project (variables only make sense in project context) # Detect undefined variables
if not self.project_id: self.undefined_variables = self._detect_undefined_variables()
return
def on_undefined_detected(undefined_vars): # Update URL entry
# Store detected undefined variables self._update_url_indicator()
self.undefined_variables = undefined_vars
# Update URL entry # Update headers
self._update_url_indicator() self._update_header_indicators()
# Update headers # Update body
self._update_header_indicators() self._update_body_indicators()
# Update body
self._update_body_indicators()
# Detect undefined variables asynchronously
self._detect_undefined_variables(on_undefined_detected)
def _update_url_indicator(self): def _update_url_indicator(self):
"""Update warning indicator on URL entry.""" """Update warning indicator on URL entry."""

View File

@ -1,13 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<gresources> <gresources>
<gresource prefix="/cz/bugsy/roster"> <gresource prefix="/cz/vesp/roster">
<file preprocess="xml-stripblanks">main-window.ui</file> <file preprocess="xml-stripblanks">main-window.ui</file>
<file preprocess="xml-stripblanks">shortcuts-dialog.ui</file> <file preprocess="xml-stripblanks">shortcuts-dialog.ui</file>
<file preprocess="xml-stripblanks">preferences-dialog.ui</file> <file preprocess="xml-stripblanks">preferences-dialog.ui</file>
<file preprocess="xml-stripblanks">icon-picker-dialog.ui</file> <file preprocess="xml-stripblanks">icon-picker-dialog.ui</file>
<file preprocess="xml-stripblanks">environments-dialog.ui</file> <file preprocess="xml-stripblanks">environments-dialog.ui</file>
<file preprocess="xml-stripblanks">export-dialog.ui</file>
<file alias="icons/export-symbolic.svg">../data/icons/hicolor/symbolic/apps/export-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

@ -24,11 +24,9 @@ import re
from pathlib import Path from pathlib import Path
from typing import Tuple, Optional, Dict, List, TYPE_CHECKING from typing import Tuple, Optional, Dict, List, TYPE_CHECKING
from dataclasses import dataclass, field from dataclasses import dataclass, field
from .constants import SCRIPT_EXECUTION_TIMEOUT_SECONDS
if TYPE_CHECKING: if TYPE_CHECKING:
from .project_manager import ProjectManager from .project_manager import ProjectManager
from .models import HttpRequest
@dataclass @dataclass
@ -39,7 +37,6 @@ class ScriptResult:
error: Optional[str] = None error: Optional[str] = None
variable_updates: Dict[str, str] = field(default_factory=dict) # Variables set by script variable_updates: Dict[str, str] = field(default_factory=dict) # Variables set by script
warnings: List[str] = field(default_factory=list) # Warnings (e.g., no env selected) warnings: List[str] = field(default_factory=list) # Warnings (e.g., no env selected)
modified_request: Optional['HttpRequest'] = None # Modified request from preprocessing
@dataclass @dataclass
@ -53,7 +50,7 @@ class ScriptContext:
class ScriptExecutor: class ScriptExecutor:
"""Executes JavaScript code using gjs (GNOME JavaScript).""" """Executes JavaScript code using gjs (GNOME JavaScript)."""
TIMEOUT_SECONDS = SCRIPT_EXECUTION_TIMEOUT_SECONDS TIMEOUT_SECONDS = 5
@staticmethod @staticmethod
def execute_postprocessing_script( def execute_postprocessing_script(
@ -160,171 +157,6 @@ print(JSON.stringify(roster.__variables));
error_msg = error.strip() if error else "Script execution failed" error_msg = error.strip() if error else "Script execution failed"
return ScriptResult(success=False, output="", error=error_msg) return ScriptResult(success=False, output="", error=error_msg)
@staticmethod
def execute_preprocessing_script(
script_code: str,
request,
context: Optional[ScriptContext] = None
) -> ScriptResult:
"""
Execute preprocessing script with request object.
Args:
script_code: JavaScript code to execute
request: HttpRequest object (will be modified)
context: Optional context for variable access and updates
Returns:
ScriptResult with output, modified request, variable updates, or error
"""
if not script_code.strip():
return ScriptResult(success=True, output="")
# Import here to avoid circular import
from .models import HttpRequest
# Create request object for JavaScript
request_obj = ScriptExecutor._create_request_object(request)
# Get environment variables (read-only)
env_vars = ScriptExecutor._get_environment_variables(context)
# Get project metadata
project_meta = ScriptExecutor._get_project_metadata(context)
# Build complete script with context
script_with_context = f"""
const request = {json.dumps(request_obj)};
// Roster API for variable management
const roster = {{
__variables: {{}}, // For setVariable
__envVariables: {json.dumps(env_vars)}, // Read-only current environment
getVariable: function(name) {{
return this.__envVariables[name] || null;
}},
setVariable: function(name, value) {{
this.__variables[String(name)] = String(value);
}},
setVariables: function(obj) {{
for (let key in obj) {{
if (obj.hasOwnProperty(key)) {{
this.__variables[String(key)] = String(obj[key]);
}}
}}
}},
project: {json.dumps(project_meta)}
}};
// Capture console.log output
let consoleOutput = [];
const console = {{
log: function(...args) {{
consoleOutput.push(args.map(arg => String(arg)).join(' '));
}}
}};
// User script
try {{
{script_code}
}} catch (error) {{
consoleOutput.push('Error: ' + error.message);
throw error;
}}
// Output results: modified request + separator + console logs + variables
print(JSON.stringify(request));
print('___ROSTER_SEPARATOR___');
print(consoleOutput.join('\\n'));
print('___ROSTER_VARIABLES___');
print(JSON.stringify(roster.__variables));
"""
# Execute with gjs
output, error, returncode = ScriptExecutor._run_gjs_script(
script_with_context,
timeout=ScriptExecutor.TIMEOUT_SECONDS
)
# Parse output and extract results
console_output = ""
variable_updates = {}
warnings = []
modified_request = None
if returncode == 0:
# Split output into: request JSON + console logs + variables JSON
parts = output.split('___ROSTER_SEPARATOR___')
if len(parts) >= 2:
request_json_str = parts[0].strip()
rest = parts[1]
# Parse modified request
try:
request_data = json.loads(request_json_str)
# Validate required fields
if not request_data.get('url') or not request_data.get('url').strip():
warnings.append("Modified request has empty URL")
return ScriptResult(
success=False,
output="",
error="Script produced invalid request: URL is empty",
warnings=warnings
)
# Create HttpRequest from modified data
modified_request = HttpRequest(
method=request_data.get('method', 'GET'),
url=request_data.get('url', ''),
headers=request_data.get('headers', {}),
body=request_data.get('body', ''),
syntax=request.syntax # Preserve syntax from original
)
except json.JSONDecodeError as e:
warnings.append(f"Failed to parse modified request: {str(e)}")
return ScriptResult(
success=False,
output="",
error="Script produced invalid request JSON",
warnings=warnings
)
# Parse console output and variables
var_parts = rest.split('___ROSTER_VARIABLES___')
console_output = var_parts[0].strip()
if len(var_parts) > 1:
try:
variables_json = var_parts[1].strip()
raw_variables = json.loads(variables_json)
# Validate variable names and add to updates
for name, value in raw_variables.items():
if ScriptExecutor._is_valid_variable_name(name):
variable_updates[name] = value
else:
warnings.append(f"Invalid variable name '{name}' (must be alphanumeric + underscore)")
except json.JSONDecodeError:
warnings.append("Failed to parse variable updates from script")
return ScriptResult(
success=True,
output=console_output,
variable_updates=variable_updates,
warnings=warnings,
modified_request=modified_request
)
else:
error_msg = error.strip() if error else "Script execution failed"
return ScriptResult(success=False, output="", error=error_msg)
@staticmethod @staticmethod
def _create_response_object(response) -> dict: def _create_response_object(response) -> dict:
"""Convert HttpResponse to JavaScript-compatible dict.""" """Convert HttpResponse to JavaScript-compatible dict."""
@ -345,47 +177,6 @@ print(JSON.stringify(roster.__variables));
'responseTime': response.response_time_ms 'responseTime': response.response_time_ms
} }
@staticmethod
def _create_request_object(request) -> dict:
"""Convert HttpRequest to JavaScript-compatible dict."""
return {
'method': request.method,
'url': request.url,
'headers': dict(request.headers), # Convert to regular dict
'body': request.body
}
@staticmethod
def _get_environment_variables(context: Optional[ScriptContext]) -> dict:
"""Get environment variables from context."""
if not context or not context.project_id or not context.environment_id:
return {}
# Load projects and find the environment
projects = context.project_manager.load_projects()
for project in projects:
if project.id == context.project_id:
for env in project.environments:
if env.id == context.environment_id:
return env.variables
return {}
@staticmethod
def _get_project_metadata(context: Optional[ScriptContext]) -> dict:
"""Get project metadata (name and environments)."""
if not context or not context.project_id:
return {'name': '', 'environments': []}
# Load projects and find the project
projects = context.project_manager.load_projects()
for project in projects:
if project.id == context.project_id:
return {
'name': project.name,
'environments': [env.name for env in project.environments]
}
return {'name': '', 'environments': []}
@staticmethod @staticmethod
def _is_valid_variable_name(name: str) -> bool: def _is_valid_variable_name(name: str) -> bool:
""" """

View File

@ -1,455 +0,0 @@
# secret_manager.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
"""
Manager for storing and retrieving sensitive variable values using GNOME Keyring.
This module provides a clean interface to libsecret for storing environment
variable values that contain sensitive data (API keys, passwords, tokens).
Uses async APIs for compatibility with the XDG Secrets Portal in sandboxed
environments (Flatpak).
Each secret is identified by:
- project_id: UUID of the project
- environment_id: UUID of the environment
- variable_name: Name of the variable
"""
import gi
gi.require_version('Secret', '1')
from gi.repository import Secret, GLib, Gio
import logging
from typing import Callable, Optional, Any
logger = logging.getLogger(__name__)
# Define schema for Roster environment variables
# This separates them from other secrets in GNOME Keyring
ROSTER_SCHEMA = Secret.Schema.new(
"cz.bugsy.roster.EnvironmentVariable",
Secret.SchemaFlags.NONE,
{
"project_id": Secret.SchemaAttributeType.STRING,
"environment_id": Secret.SchemaAttributeType.STRING,
"variable_name": Secret.SchemaAttributeType.STRING,
}
)
class SecretManager:
"""Manages sensitive variable storage using GNOME Keyring via libsecret.
All methods use async APIs for compatibility with the XDG Secrets Portal.
"""
def __init__(self):
"""Initialize the secret manager."""
self.schema = ROSTER_SCHEMA
def store_secret(self, project_id: str, environment_id: str,
variable_name: str, value: str, project_name: str = "",
environment_name: str = "",
callback: Optional[Callable[[bool], None]] = None):
"""
Store a sensitive variable value in GNOME Keyring (async).
Args:
project_id: UUID of the project
environment_id: UUID of the environment
variable_name: Name of the variable
value: The secret value to store
project_name: Optional human-readable project name (for display in Seahorse)
environment_name: Optional human-readable environment name (for display)
callback: Optional callback function called with success boolean
"""
# Create a descriptive label for the keyring UI
label = f"Roster: {project_name}/{environment_name}/{variable_name}" if project_name else \
f"Roster Variable: {variable_name}"
# Build attributes dictionary for the secret
attributes = {
"project_id": project_id,
"environment_id": environment_id,
"variable_name": variable_name,
}
def on_store_complete(source, result, user_data):
try:
Secret.password_store_finish(result)
logger.info(f"Stored secret for {variable_name} in {environment_id}")
if callback:
callback(True)
except GLib.Error as e:
logger.error(f"Failed to store secret: {e.message}")
if callback:
callback(False)
# Store the secret asynchronously
Secret.password_store(
self.schema,
attributes,
Secret.COLLECTION_DEFAULT,
label,
value,
None, # cancellable
on_store_complete,
None # user_data
)
def retrieve_secret(self, project_id: str, environment_id: str,
variable_name: str,
callback: Callable[[Optional[str]], None]):
"""
Retrieve a sensitive variable value from GNOME Keyring (async).
Args:
project_id: UUID of the project
environment_id: UUID of the environment
variable_name: Name of the variable
callback: Callback function called with the secret value (or None if not found)
"""
attributes = {
"project_id": project_id,
"environment_id": environment_id,
"variable_name": variable_name,
}
def on_lookup_complete(source, result, user_data):
try:
value = Secret.password_lookup_finish(result)
if value is None:
logger.debug(f"No secret found for {variable_name}")
else:
logger.debug(f"Retrieved secret for {variable_name}")
callback(value)
except GLib.Error as e:
logger.error(f"Failed to retrieve secret: {e.message}")
callback(None)
# Retrieve the secret asynchronously
Secret.password_lookup(
self.schema,
attributes,
None, # cancellable
on_lookup_complete,
None # user_data
)
def delete_secret(self, project_id: str, environment_id: str,
variable_name: str,
callback: Optional[Callable[[bool], None]] = None):
"""
Delete a sensitive variable value from GNOME Keyring (async).
Args:
project_id: UUID of the project
environment_id: UUID of the environment
variable_name: Name of the variable
callback: Optional callback function called with success boolean
"""
attributes = {
"project_id": project_id,
"environment_id": environment_id,
"variable_name": variable_name,
}
def on_clear_complete(source, result, user_data):
try:
deleted = Secret.password_clear_finish(result)
if deleted:
logger.info(f"Deleted secret for {variable_name}")
else:
logger.debug(f"No secret to delete for {variable_name}")
if callback:
callback(True)
except GLib.Error as e:
logger.error(f"Failed to delete secret: {e.message}")
if callback:
callback(False)
# Delete the secret asynchronously
Secret.password_clear(
self.schema,
attributes,
None, # cancellable
on_clear_complete,
None # user_data
)
def delete_all_project_secrets(self, project_id: str,
callback: Optional[Callable[[bool], None]] = None):
"""
Delete all secrets for a project (when project is deleted).
Args:
project_id: UUID of the project
callback: Optional callback function called with success boolean
"""
attributes = {
"project_id": project_id,
}
def on_clear_complete(source, result, user_data):
try:
Secret.password_clear_finish(result)
logger.info(f"Deleted all secrets for project {project_id}")
if callback:
callback(True)
except GLib.Error as e:
logger.error(f"Failed to delete project secrets: {e.message}")
if callback:
callback(False)
# Delete all matching secrets asynchronously
Secret.password_clear(
self.schema,
attributes,
None,
on_clear_complete,
None
)
def delete_all_environment_secrets(self, project_id: str, environment_id: str,
callback: Optional[Callable[[bool], None]] = None):
"""
Delete all secrets for an environment (when environment is deleted).
Args:
project_id: UUID of the project
environment_id: UUID of the environment
callback: Optional callback function called with success boolean
"""
attributes = {
"project_id": project_id,
"environment_id": environment_id,
}
def on_clear_complete(source, result, user_data):
try:
Secret.password_clear_finish(result)
logger.info(f"Deleted all secrets for environment {environment_id}")
if callback:
callback(True)
except GLib.Error as e:
logger.error(f"Failed to delete environment secrets: {e.message}")
if callback:
callback(False)
Secret.password_clear(
self.schema,
attributes,
None,
on_clear_complete,
None
)
def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str,
callback: Optional[Callable[[bool], None]] = None):
"""
Rename a variable across all environments (updates secret keys).
This is called when a variable is renamed. We need to:
1. Find all secrets with old_name
2. Store them with new_name
3. Delete old secrets
Args:
project_id: UUID of the project
old_name: Current variable name
new_name: New variable name
callback: Optional callback function called with success boolean
"""
attributes = {
"project_id": project_id,
"variable_name": old_name,
}
def on_search_complete(source, result, user_data):
try:
secrets = Secret.password_search_finish(result)
if not secrets:
logger.debug(f"No secrets found for variable {old_name}")
if callback:
callback(True)
return
# Track how many secrets we need to process
pending_count = [len(secrets)]
all_success = [True]
def on_rename_done(success):
pending_count[0] -= 1
if not success:
all_success[0] = False
if pending_count[0] == 0:
logger.info(f"Renamed variable secrets from {old_name} to {new_name}")
if callback:
callback(all_success[0])
# For each secret, retrieve value, store with new name, delete old
for item in secrets:
self._rename_single_secret(item, project_id, old_name, new_name, on_rename_done)
except GLib.Error as e:
logger.error(f"Failed to search for variable secrets: {e.message}")
if callback:
callback(False)
# Search for matching secrets asynchronously
Secret.password_search(
self.schema,
attributes,
Secret.SearchFlags.ALL,
None,
on_search_complete,
None
)
def _rename_single_secret(self, item, project_id: str, old_name: str,
new_name: str, callback: Callable[[bool], None]):
"""Helper to rename a single secret item."""
def on_load_complete(item, result, user_data):
try:
item.load_secret_finish(result)
secret = item.get_secret()
if secret is None:
callback(False)
return
value = secret.get_text()
attrs = item.get_attributes()
environment_id = attrs.get("environment_id")
if not environment_id:
logger.warning("Secret missing environment_id, skipping")
callback(False)
return
# Store with new name, then delete old
def on_store_done(success):
if success:
self.delete_secret(project_id, environment_id, old_name, callback)
else:
callback(False)
self.store_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=new_name,
value=value,
callback=on_store_done
)
except GLib.Error as e:
logger.error(f"Failed to load secret for rename: {e.message}")
callback(False)
item.load_secret(None, on_load_complete, None)
# ========== Batch Operations ==========
def retrieve_multiple_secrets(self, project_id: str, environment_id: str,
variable_names: list,
callback: Callable[[dict], None]):
"""
Retrieve multiple secrets at once (async).
Args:
project_id: UUID of the project
environment_id: UUID of the environment
variable_names: List of variable names to retrieve
callback: Callback function called with dict of {variable_name: value}
"""
if not variable_names:
callback({})
return
results = {}
pending_count = [len(variable_names)]
def on_single_retrieve(var_name):
def handler(value):
results[var_name] = value
pending_count[0] -= 1
if pending_count[0] == 0:
callback(results)
return handler
for var_name in variable_names:
self.retrieve_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=var_name,
callback=on_single_retrieve(var_name)
)
def store_multiple_secrets(self, project_id: str, environment_id: str,
variables: dict, project_name: str = "",
environment_name: str = "",
callback: Optional[Callable[[bool], None]] = None):
"""
Store multiple secrets at once (async).
Args:
project_id: UUID of the project
environment_id: UUID of the environment
variables: Dict of {variable_name: value} to store
project_name: Optional project name for display
environment_name: Optional environment name for display
callback: Optional callback called with overall success boolean
"""
if not variables:
if callback:
callback(True)
return
pending_count = [len(variables)]
all_success = [True]
def on_single_store(success):
if not success:
all_success[0] = False
pending_count[0] -= 1
if pending_count[0] == 0 and callback:
callback(all_success[0])
for var_name, value in variables.items():
self.store_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=var_name,
value=value,
project_name=project_name,
environment_name=environment_name,
callback=on_single_store
)
# Singleton instance
_secret_manager = None
def get_secret_manager() -> SecretManager:
"""Get the global SecretManager instance."""
global _secret_manager
if _secret_manager is None:
_secret_manager = SecretManager()
return _secret_manager

View File

@ -26,18 +26,6 @@
<property name="accelerator">&lt;Control&gt;s</property> <property name="accelerator">&lt;Control&gt;s</property>
</object> </object>
</child> </child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Focus URL Field</property>
<property name="accelerator">&lt;Control&gt;l</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Send Request</property>
<property name="accelerator">&lt;Control&gt;Return</property>
</object>
</child>
<child> <child>
<object class="GtkShortcutsShortcut"> <object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Show Shortcuts</property> <property name="title" translatable="yes" context="shortcut window">Show Shortcuts</property>

View File

@ -7,7 +7,6 @@
# 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
@ -21,13 +20,10 @@
from typing import List, Optional from typing import List, Optional
import uuid import uuid
import json import json
import logging
from pathlib import Path from pathlib import Path
from .models import RequestTab, HttpRequest, HttpResponse from .models import RequestTab, HttpRequest, HttpResponse
logger = logging.getLogger(__name__)
class TabManager: class TabManager:
"""Manages open request tabs.""" """Manages open request tabs."""
@ -49,10 +45,7 @@ class TabManager:
# Make a copy to avoid reference issues # Make a copy to avoid reference issues
# Create original for saved requests or loaded history items (anything with content) # Create original for saved requests or loaded history items (anything with content)
# Only skip for truly blank new requests # Only skip for truly blank new requests
# Consider headers empty if they only contain default headers has_content = bool(request.url or request.body or request.headers)
has_only_default_headers = request.headers == HttpRequest.default_headers()
has_non_default_headers = request.headers and not has_only_default_headers
has_content = bool(request.url or request.body or has_non_default_headers)
original_request = HttpRequest( original_request = HttpRequest(
method=request.method, method=request.method,
url=request.url, url=request.url,
@ -154,6 +147,6 @@ class TabManager:
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e: except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
# If session file is invalid, start fresh # If session file is invalid, start fresh
logger.warning(f"Failed to load session: {e}") print(f"Failed to load session: {e}")
self.tabs = [] self.tabs = []
self.active_tab_id = None self.active_tab_id = None

View File

@ -7,7 +7,6 @@
# 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
@ -65,11 +64,8 @@ class VariableSubstitution:
var_name = match.group(1) var_name = match.group(1)
if var_name in variables: if var_name in variables:
value = variables[var_name] value = variables[var_name]
# Check if value is empty/None - treat as undefined for warning purposes # Replace with value or empty string if value is None/empty
if not value: return value if value else ""
undefined_vars.append(var_name)
return ""
return value
else: else:
# Variable not defined - track it and replace with empty string # Variable not defined - track it and replace with empty string
undefined_vars.append(var_name) undefined_vars.append(var_name)
@ -127,118 +123,3 @@ class VariableSubstitution:
) )
return new_request, all_undefined return new_request, all_undefined
@staticmethod
def substitute_excluding_variables(text: str, variables: Dict[str, str], excluded_vars: List[str]) -> Tuple[str, List[str]]:
"""
Replace {{variable_name}} with values, but skip excluded variables (leave as {{var}}).
Args:
text: The text containing variable placeholders
variables: Dictionary mapping variable names to their values
excluded_vars: List of variable names to skip (leave as placeholders)
Returns:
Tuple of (substituted_text, list_of_undefined_variables)
"""
if not text:
return text, []
undefined_vars = []
def replace_var(match):
var_name = match.group(1)
# Skip excluded variables - leave as {{var_name}}
if var_name in excluded_vars:
return match.group(0) # Return original {{var_name}}
# Substitute non-excluded variables
if var_name in variables:
value = variables[var_name]
# Check if value is empty/None - treat as undefined for warning purposes
if not value:
undefined_vars.append(var_name)
return ""
return value
else:
# Variable not defined - track it and replace with empty string
undefined_vars.append(var_name)
return ""
substituted = VariableSubstitution.VARIABLE_PATTERN.sub(replace_var, text)
return substituted, undefined_vars
@staticmethod
def substitute_request_excluding_sensitive(
request: HttpRequest,
environment: Environment,
sensitive_variables: List[str]
) -> Tuple[HttpRequest, Set[str], bool]:
"""
Substitute variables in request, but exclude sensitive variables (leave as {{var}}).
This is useful for saving requests to history without exposing sensitive values.
Sensitive variables remain in their {{variable_name}} placeholder form.
Args:
request: The HTTP request with variable placeholders
environment: The environment containing variable values
sensitive_variables: List of variable names to exclude from substitution
Returns:
Tuple of (redacted_request, set_of_undefined_variables, has_sensitive_vars)
- redacted_request: Request with only non-sensitive variables substituted
- set_of_undefined_variables: Variables that were referenced but not defined
- has_sensitive_vars: True if any sensitive variables were found and redacted
"""
all_undefined = set()
# Substitute URL (excluding sensitive variables)
new_url, url_undefined = VariableSubstitution.substitute_excluding_variables(
request.url, environment.variables, sensitive_variables
)
all_undefined.update(url_undefined)
# Substitute headers (both keys and values, excluding sensitive variables)
new_headers = {}
for key, value in request.headers.items():
new_key, key_undefined = VariableSubstitution.substitute_excluding_variables(
key, environment.variables, sensitive_variables
)
new_value, value_undefined = VariableSubstitution.substitute_excluding_variables(
value, environment.variables, sensitive_variables
)
all_undefined.update(key_undefined)
all_undefined.update(value_undefined)
new_headers[new_key] = new_value
# Substitute body (excluding sensitive variables)
new_body, body_undefined = VariableSubstitution.substitute_excluding_variables(
request.body, environment.variables, sensitive_variables
)
all_undefined.update(body_undefined)
# Create new HttpRequest with redacted values
redacted_request = HttpRequest(
method=request.method,
url=new_url,
headers=new_headers,
body=new_body,
syntax=request.syntax
)
# Check if any sensitive variables were actually used in the request
all_vars_in_request = set()
all_vars_in_request.update(VariableSubstitution.find_variables(request.url))
all_vars_in_request.update(VariableSubstitution.find_variables(request.body))
for key, value in request.headers.items():
all_vars_in_request.update(VariableSubstitution.find_variables(key))
all_vars_in_request.update(VariableSubstitution.find_variables(value))
# Determine if any sensitive variables were actually present
has_sensitive_vars = bool(
set(sensitive_variables) & all_vars_in_request
)
return redacted_request, all_undefined, has_sensitive_vars

View File

@ -7,7 +7,6 @@
# 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

View File

@ -7,7 +7,6 @@
# 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
@ -21,7 +20,7 @@
from gi.repository import Gtk, GObject from gi.repository import Gtk, GObject
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-column-header.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/environment-column-header.ui')
class EnvironmentColumnHeader(Gtk.Box): class EnvironmentColumnHeader(Gtk.Box):
"""Widget for displaying an environment column header with edit/delete buttons.""" """Widget for displaying an environment column header with edit/delete buttons."""

View File

@ -7,7 +7,6 @@
# 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
@ -22,7 +21,7 @@ from gi.repository import Gtk, GObject
from .environment_column_header import EnvironmentColumnHeader from .environment_column_header import EnvironmentColumnHeader
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-header-row.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/environment-header-row.ui')
class EnvironmentHeaderRow(Gtk.Box): class EnvironmentHeaderRow(Gtk.Box):
"""Widget for the header row containing all environment column headers.""" """Widget for the header row containing all environment column headers."""

View File

@ -7,7 +7,6 @@
# 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
@ -21,7 +20,7 @@
from gi.repository import Gtk, GObject from gi.repository import Gtk, GObject
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-row.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/environment-row.ui')
class EnvironmentRow(Gtk.Box): class EnvironmentRow(Gtk.Box):
"""Widget for displaying and editing an environment with its variables.""" """Widget for displaying and editing an environment with its variables."""

View File

@ -7,7 +7,6 @@
# 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
@ -21,7 +20,7 @@
from gi.repository import Gtk, GObject from gi.repository import Gtk, GObject
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/header-row.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/header-row.ui')
class HeaderRow(Gtk.Box): class HeaderRow(Gtk.Box):
"""Widget for editing a single HTTP header key-value pair.""" """Widget for editing a single HTTP header key-value pair."""

View File

@ -62,19 +62,6 @@
</object> </object>
</child> </child>
<!-- Redaction Indicator (shown when sensitive variables were redacted) -->
<child>
<object class="GtkImage" id="redaction_indicator">
<property name="icon-name">dialog-information-symbolic</property>
<property name="tooltip-text">Sensitive variables were redacted from this history entry</property>
<property name="valign">center</property>
<property name="visible">False</property>
<style>
<class name="warning"/>
</style>
</object>
</child>
<!-- Delete Button --> <!-- Delete Button -->
<child> <child>
<object class="GtkButton" id="delete_button"> <object class="GtkButton" id="delete_button">

View File

@ -7,7 +7,6 @@
# 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
@ -22,7 +21,7 @@ from gi.repository import Gtk, GObject, Gdk
from datetime import datetime from datetime import datetime
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/history-item.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/history-item.ui')
class HistoryItem(Gtk.Box): class HistoryItem(Gtk.Box):
"""Widget for displaying a history entry.""" """Widget for displaying a history entry."""
@ -34,7 +33,6 @@ class HistoryItem(Gtk.Box):
url_label = Gtk.Template.Child() url_label = Gtk.Template.Child()
timestamp_label = Gtk.Template.Child() timestamp_label = Gtk.Template.Child()
status_label = Gtk.Template.Child() status_label = Gtk.Template.Child()
redaction_indicator = Gtk.Template.Child()
delete_button = Gtk.Template.Child() delete_button = Gtk.Template.Child()
request_headers_label = Gtk.Template.Child() request_headers_label = Gtk.Template.Child()
request_body_scroll = Gtk.Template.Child() request_body_scroll = Gtk.Template.Child()
@ -78,10 +76,6 @@ class HistoryItem(Gtk.Box):
self.timestamp_label.set_text(timestamp_str) self.timestamp_label.set_text(timestamp_str)
# Show redaction indicator if sensitive variables were redacted
if self.entry.has_redacted_variables:
self.redaction_indicator.set_visible(True)
# Status # Status
if self.entry.response: if self.entry.response:
status_text = f"{self.entry.response.status_code} {self.entry.response.status_text}" status_text = f"{self.entry.response.status_code} {self.entry.response.status_text}"

View File

@ -29,7 +29,7 @@
<property name="spacing">0</property> <property name="spacing">0</property>
<child> <child>
<object class="GtkBox" id="header_box"> <object class="GtkBox">
<property name="orientation">horizontal</property> <property name="orientation">horizontal</property>
<property name="spacing">6</property> <property name="spacing">6</property>
<property name="margin-start">6</property> <property name="margin-start">6</property>
@ -72,7 +72,7 @@
<child> <child>
<object class="GtkListBox" id="requests_listbox"> <object class="GtkListBox" id="requests_listbox">
<property name="margin-start">12</property> <property name="margin-start">24</property>
<style> <style>
<class name="navigation-sidebar"/> <class name="navigation-sidebar"/>
</style> </style>

View File

@ -7,7 +7,6 @@
# 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
@ -22,13 +21,12 @@ from gi.repository import Gtk, GObject, Gio
from .request_item import RequestItem from .request_item import RequestItem
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/project-item.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/project-item.ui')
class ProjectItem(Gtk.Box): class ProjectItem(Gtk.Box):
"""Widget for displaying a project with its requests.""" """Widget for displaying a project with its requests."""
__gtype_name__ = 'ProjectItem' __gtype_name__ = 'ProjectItem'
header_box = Gtk.Template.Child()
project_icon = Gtk.Template.Child() project_icon = Gtk.Template.Child()
name_label = Gtk.Template.Child() name_label = Gtk.Template.Child()
requests_revealer = Gtk.Template.Child() requests_revealer = Gtk.Template.Child()
@ -53,7 +51,7 @@ class ProjectItem(Gtk.Box):
# Click gesture for header # Click gesture for header
gesture = Gtk.GestureClick.new() gesture = Gtk.GestureClick.new()
gesture.connect('released', self._on_header_clicked) gesture.connect('released', self._on_header_clicked)
self.header_box.add_controller(gesture) self.add_controller(gesture)
def _setup_actions(self): def _setup_actions(self):
"""Setup action group for menu.""" """Setup action group for menu."""

View File

@ -7,7 +7,6 @@
# 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
@ -21,7 +20,7 @@
from gi.repository import Gtk, GObject from gi.repository import Gtk, GObject
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/request-item.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/request-item.ui')
class RequestItem(Gtk.Box): class RequestItem(Gtk.Box):
"""Widget for displaying a saved request.""" """Widget for displaying a saved request."""

View File

@ -19,67 +19,23 @@
<object class="GtkEntry" id="name_entry"> <object class="GtkEntry" id="name_entry">
<property name="placeholder-text">Variable name</property> <property name="placeholder-text">Variable name</property>
<property name="hexpand">True</property> <property name="hexpand">True</property>
<signal name="changed" handler="on_name_changed"/>
</object> </object>
</child> </child>
</object>
</child>
<!-- Inline edit buttons (shown only when editing) -->
<child>
<object class="GtkRevealer" id="edit_buttons_revealer">
<property name="transition-type">slide-left</property>
<property name="reveal-child">False</property>
<child> <child>
<object class="GtkBox"> <object class="GtkButton" id="delete_button">
<property name="spacing">3</property> <property name="icon-name">edit-delete-symbolic</property>
<child> <property name="tooltip-text">Delete variable</property>
<object class="GtkButton" id="cancel_button"> <signal name="clicked" handler="on_delete_clicked"/>
<property name="icon-name">process-stop-symbolic</property> <style>
<property name="tooltip-text">Cancel editing</property> <class name="flat"/>
<signal name="clicked" handler="on_cancel_clicked"/> </style>
<style>
<class name="flat"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="save_button">
<property name="icon-name">object-select-symbolic</property>
<property name="tooltip-text">Save changes</property>
<signal name="clicked" handler="on_save_clicked"/>
<style>
<class name="flat"/>
<class name="suggested-action"/>
</style>
</object>
</child>
</object> </object>
</child> </child>
</object> </object>
</child> </child>
<child>
<object class="GtkToggleButton" id="sensitive_toggle">
<property name="icon-name">changes-allow-symbolic</property>
<property name="tooltip-text">Mark as sensitive (store in keyring)</property>
<signal name="toggled" handler="on_sensitive_toggled"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="delete_button">
<property name="icon-name">edit-delete-symbolic</property>
<property name="tooltip-text">Delete variable</property>
<signal name="clicked" handler="on_delete_clicked"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
<child> <child>
<object class="GtkBox" id="values_box"> <object class="GtkBox" id="values_box">
<property name="orientation">horizontal</property> <property name="orientation">horizontal</property>

View File

@ -7,7 +7,6 @@
# 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
@ -22,7 +21,7 @@ from gi.repository import Gtk, GObject, GLib
from datetime import datetime, timedelta from datetime import datetime, timedelta
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/variable-data-row.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/variable-data-row.ui')
class VariableDataRow(Gtk.Box): class VariableDataRow(Gtk.Box):
"""Widget for a data row with variable name and values for each environment.""" """Widget for a data row with variable name and values for each environment."""
@ -30,10 +29,6 @@ class VariableDataRow(Gtk.Box):
variable_cell = Gtk.Template.Child() variable_cell = Gtk.Template.Child()
name_entry = Gtk.Template.Child() name_entry = Gtk.Template.Child()
edit_buttons_revealer = Gtk.Template.Child()
cancel_button = Gtk.Template.Child()
save_button = Gtk.Template.Child()
sensitive_toggle = Gtk.Template.Child()
delete_button = Gtk.Template.Child() delete_button = Gtk.Template.Child()
values_box = Gtk.Template.Child() values_box = Gtk.Template.Child()
@ -41,41 +36,19 @@ class VariableDataRow(Gtk.Box):
'variable-changed': (GObject.SIGNAL_RUN_FIRST, None, ()), 'variable-changed': (GObject.SIGNAL_RUN_FIRST, None, ()),
'variable-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'variable-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'value-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, str)), # env_id, value 'value-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, str)), # env_id, value
'sensitivity-changed': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), # is_sensitive
} }
def __init__(self, variable_name, environments, size_group, project, project_manager, update_timestamp=None): def __init__(self, variable_name, environments, size_group, update_timestamp=None):
super().__init__() super().__init__()
self.variable_name = variable_name self.variable_name = variable_name
self.original_name = variable_name # Store original name for cancel
self.environments = environments self.environments = environments
self.size_group = size_group self.size_group = size_group
self.project = project
self.project_manager = project_manager
self.value_entries = {} self.value_entries = {}
self.update_timeout_id = None self.update_timeout_id = None
self._updating_toggle = False # Flag to prevent recursion
self._is_editing = False # Track editing state
# Set variable name # Set variable name
self.name_entry.set_text(variable_name) self.name_entry.set_text(variable_name)
# Connect focus events for inline editing
focus_controller = Gtk.EventControllerFocus()
focus_controller.connect('enter', self._on_name_entry_focus_in)
focus_controller.connect('leave', self._on_name_entry_focus_out)
self.name_entry.add_controller(focus_controller)
# Connect activate signal (Enter key)
self.name_entry.connect('activate', self._on_name_entry_activate)
# Set initial sensitivity state
is_sensitive = variable_name in self.project.sensitive_variables
self._updating_toggle = True
self.sensitive_toggle.set_active(is_sensitive)
self._update_sensitive_icon(is_sensitive)
self._updating_toggle = False
# Add variable cell to size group for alignment # Add variable cell to size group for alignment
if size_group: if size_group:
size_group.add_widget(self.variable_cell) size_group.add_widget(self.variable_cell)
@ -94,9 +67,6 @@ class VariableDataRow(Gtk.Box):
self.value_entries = {} self.value_entries = {}
# Check if this variable is sensitive
is_sensitive = self.variable_name in self.project.sensitive_variables
# Create entry for each environment # Create entry for each environment
for env in self.environments: for env in self.environments:
# Create a box to hold the entry (for size group alignment) # Create a box to hold the entry (for size group alignment)
@ -105,13 +75,9 @@ class VariableDataRow(Gtk.Box):
entry = Gtk.Entry() entry = Gtk.Entry()
entry.set_placeholder_text(env.name) entry.set_placeholder_text(env.name)
entry.set_text(env.variables.get(self.variable_name, ""))
entry.set_hexpand(True) entry.set_hexpand(True)
entry.connect('changed', self._on_value_changed, env.id)
# Configure entry for sensitive variables
if is_sensitive:
entry.set_visibility(False) # Show bullets instead of text
entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
entry.add_css_class("sensitive-variable")
entry_box.append(entry) entry_box.append(entry)
@ -122,122 +88,27 @@ class VariableDataRow(Gtk.Box):
self.values_box.append(entry_box) self.values_box.append(entry_box)
self.value_entries[env.id] = entry self.value_entries[env.id] = entry
# Get value from appropriate storage (keyring or JSON) asynchronously
def on_value_retrieved(value, entry=entry, env_id=env.id):
entry.set_text(value)
# Connect changed handler after setting initial value to avoid spurious signals
entry.connect('changed', self._on_value_changed, env_id)
self.project_manager.get_variable_value(
self.project.id,
env.id,
self.variable_name,
on_value_retrieved
)
def _on_value_changed(self, entry, env_id): def _on_value_changed(self, entry, env_id):
"""Handle value entry changes.""" """Handle value entry changes."""
value = entry.get_text() self.emit('value-changed', env_id, entry.get_text())
# Emit signal first (value is being changed)
self.emit('value-changed', env_id, value)
# Use project_manager to store value in appropriate location (async)
self.project_manager.set_variable_value(
self.project.id,
env_id,
self.variable_name,
value,
callback=None # Fire and forget - errors are logged
)
def _on_name_entry_focus_in(self, controller):
"""Show edit buttons when entry gains focus."""
self._is_editing = True
self.original_name = self.name_entry.get_text().strip()
self.edit_buttons_revealer.set_reveal_child(True)
def _on_name_entry_focus_out(self, controller):
"""Handle focus leaving the entry without explicit save/cancel."""
# Only auto-hide if user didn't click cancel/save
# The buttons will handle hiding themselves
pass
def _on_name_entry_activate(self, entry):
"""Handle Enter key press - same as clicking Save."""
self._save_changes()
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_cancel_clicked(self, button): def on_name_changed(self, entry):
"""Cancel editing and restore original name.""" """Handle variable name changes."""
self.name_entry.set_text(self.original_name) self.emit('variable-changed')
self._is_editing = False
self.edit_buttons_revealer.set_reveal_child(False)
@Gtk.Template.Callback()
def on_save_clicked(self, button):
"""Save variable name changes."""
self._save_changes()
def _save_changes(self):
"""Save the variable name change."""
new_name = self.name_entry.get_text().strip()
# Only emit if name actually changed
if new_name != self.original_name:
self.emit('variable-changed')
self._is_editing = False
self.edit_buttons_revealer.set_reveal_child(False)
self.original_name = new_name
@Gtk.Template.Callback() @Gtk.Template.Callback()
def on_delete_clicked(self, button): def on_delete_clicked(self, button):
"""Handle delete button click.""" """Handle delete button click."""
self.emit('variable-delete-requested') self.emit('variable-delete-requested')
@Gtk.Template.Callback()
def on_sensitive_toggled(self, toggle_button):
"""Handle sensitivity toggle."""
if self._updating_toggle:
return
is_sensitive = toggle_button.get_active()
self._update_sensitive_icon(is_sensitive)
# Emit signal so parent can update the project
self.emit('sensitivity-changed', is_sensitive)
def _update_sensitive_icon(self, is_sensitive):
"""Update the lock icon based on sensitivity state."""
if is_sensitive:
self.sensitive_toggle.set_icon_name("channel-secure-symbolic")
self.sensitive_toggle.set_tooltip_text("Sensitive (stored in keyring)\nClick to make non-sensitive")
self.name_entry.add_css_class("sensitive-variable-name")
else:
self.sensitive_toggle.set_icon_name("changes-allow-symbolic")
self.sensitive_toggle.set_tooltip_text("Not sensitive (stored in JSON)\nClick to mark as sensitive")
self.name_entry.remove_css_class("sensitive-variable-name")
def get_variable_name(self): def get_variable_name(self):
"""Return current variable name.""" """Return current variable name."""
return self.name_entry.get_text().strip() return self.name_entry.get_text().strip()
def refresh(self, environments, project): def refresh(self, environments):
"""Refresh with updated environments and project.""" """Refresh with updated environments list."""
self.environments = environments self.environments = environments
self.project = project
# Update original_name in case variable was renamed
self.original_name = self.variable_name
# Update sensitivity toggle state
is_sensitive = self.variable_name in self.project.sensitive_variables
self._updating_toggle = True
self.sensitive_toggle.set_active(is_sensitive)
self._update_sensitive_icon(is_sensitive)
self._updating_toggle = False
self._populate_values() self._populate_values()
def _apply_update_marking(self, timestamp_str): def _apply_update_marking(self, timestamp_str):

View File

@ -7,7 +7,6 @@
# 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
@ -21,7 +20,7 @@
from gi.repository import Gtk, GObject from gi.repository import Gtk, GObject
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/variable-row.ui') @Gtk.Template(resource_path='/cz/vesp/roster/widgets/variable-row.ui')
class VariableRow(Gtk.Box): class VariableRow(Gtk.Box):
"""Widget for editing a variable name.""" """Widget for editing a variable name."""

View File

@ -20,16 +20,7 @@
import gi import gi
gi.require_version('GtkSource', '5') gi.require_version('GtkSource', '5')
from gi.repository import Adw, Gtk, GLib, Gio, GtkSource from gi.repository import Adw, Gtk, GLib, Gio, GtkSource
from typing import Dict, Optional
import logging
from .models import HttpRequest, HttpResponse, HistoryEntry, RequestTab from .models import HttpRequest, HttpResponse, HistoryEntry, RequestTab
from .constants import (
UI_TOAST_TIMEOUT_SECONDS,
DELAY_TAB_CREATION_MS,
DISPLAY_URL_NAME_MAX_LENGTH,
PROJECT_NAME_MAX_LENGTH,
REQUEST_NAME_MAX_LENGTH,
)
from .http_client import HttpClient from .http_client import HttpClient
from .history_manager import HistoryManager from .history_manager import HistoryManager
from .project_manager import ProjectManager from .project_manager import ProjectManager
@ -43,19 +34,13 @@ from datetime import datetime
import json import json
import uuid import uuid
logger = logging.getLogger(__name__)
@Gtk.Template(resource_path='/cz/vesp/roster/main-window.ui')
@Gtk.Template(resource_path='/cz/bugsy/roster/main-window.ui')
class RosterWindow(Adw.ApplicationWindow): class RosterWindow(Adw.ApplicationWindow):
__gtype_name__ = 'RosterWindow' __gtype_name__ = 'RosterWindow'
# Toast overlay
toast_overlay = Gtk.Template.Child()
# Top bar widgets # Top bar widgets
save_request_button = Gtk.Template.Child() save_request_button = Gtk.Template.Child()
export_request_button = Gtk.Template.Child()
new_request_button = Gtk.Template.Child() new_request_button = Gtk.Template.Child()
tab_view = Gtk.Template.Child() tab_view = Gtk.Template.Child()
tab_bar = Gtk.Template.Child() tab_bar = Gtk.Template.Child()
@ -79,13 +64,13 @@ class RosterWindow(Adw.ApplicationWindow):
self.tab_manager = TabManager() self.tab_manager = TabManager()
# Map AdwTabPage to tab data # Map AdwTabPage to tab data
self.page_to_widget: Dict[Adw.TabPage, RequestTabWidget] = {} self.page_to_widget = {} # AdwTabPage -> RequestTabWidget
self.page_to_tab: Dict[Adw.TabPage, RequestTab] = {} self.page_to_tab = {} # AdwTabPage -> RequestTab
self.current_tab_id: Optional[str] = None self.current_tab_id = None
# Track recently updated variables for visual marking # Track recently updated variables for visual marking
# Format: {project_id: {var_name: timestamp}} # Format: {project_id: {var_name: timestamp}}
self.recently_updated_variables: Dict[str, Dict[str, str]] = {} self.recently_updated_variables = {}
# Connect to tab view signals # Connect to tab view signals
self.tab_view.connect('close-page', self._on_tab_close_page) self.tab_view.connect('close-page', self._on_tab_close_page)
@ -108,7 +93,7 @@ class RosterWindow(Adw.ApplicationWindow):
# Create first tab # Create first tab
self._create_new_tab() self._create_new_tab()
def _on_close_request(self, window) -> bool: def _on_close_request(self, window):
"""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
modified_tabs = [] modified_tabs = []
@ -143,13 +128,13 @@ class RosterWindow(Adw.ApplicationWindow):
# No unsaved changes, allow closing # No unsaved changes, allow closing
return False return False
def _on_close_app_dialog_response(self, dialog, response) -> None: def _on_close_app_dialog_response(self, dialog, response):
"""Handle close application dialog response.""" """Handle close application dialog response."""
if response == "close": if response == "close":
# User confirmed - close the window # User confirmed - close the window
self.destroy() self.destroy()
def _setup_custom_css(self) -> None: def _setup_custom_css(self):
"""Setup custom CSS for UI styling.""" """Setup custom CSS for UI styling."""
css_provider = Gtk.CssProvider() css_provider = Gtk.CssProvider()
css_provider.load_from_data(b""" css_provider.load_from_data(b"""
@ -165,7 +150,9 @@ class RosterWindow(Adw.ApplicationWindow):
} }
stackswitcher button:checked { stackswitcher button:checked {
font-weight: 900; background: @accent_bg_color;
color: @accent_fg_color;
font-weight: 600;
} }
/* Warning styling for undefined variables */ /* Warning styling for undefined variables */
@ -188,16 +175,6 @@ class RosterWindow(Adw.ApplicationWindow):
mix(@success_bg_color, @view_bg_color, 0.35), mix(@success_bg_color, @view_bg_color, 0.35),
mix(@success_bg_color, @view_bg_color, 0.15)); mix(@success_bg_color, @view_bg_color, 0.15));
} }
/* Sensitive variable styling */
entry.sensitive-variable {
font-family: monospace;
}
entry.sensitive-variable-name {
color: @accent_color;
font-weight: 600;
}
""") """)
Gtk.StyleContext.add_provider_for_display( Gtk.StyleContext.add_provider_for_display(
@ -206,12 +183,12 @@ class RosterWindow(Adw.ApplicationWindow):
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
) )
def _setup_tab_system(self) -> None: def _setup_tab_system(self):
"""Set up the tab system.""" """Set up the tab system."""
# Connect new request button # Connect new request button
self.new_request_button.connect("clicked", self._on_new_request_clicked) self.new_request_button.connect("clicked", self._on_new_request_clicked)
def _on_tab_selected(self, tab_view, param) -> None: def _on_tab_selected(self, tab_view, param):
"""Handle tab selection change.""" """Handle tab selection change."""
page = tab_view.get_selected_page() page = tab_view.get_selected_page()
if not page: if not page:
@ -222,7 +199,7 @@ class RosterWindow(Adw.ApplicationWindow):
if tab: if tab:
self.current_tab_id = tab.id self.current_tab_id = tab.id
def _on_tab_close_page(self, tab_view, page) -> bool: def _on_tab_close_page(self, tab_view, page):
"""Handle tab close request.""" """Handle tab close request."""
# Get the RequestTab and widget for this page # Get the RequestTab and widget for this page
tab = self.page_to_tab.get(page) tab = self.page_to_tab.get(page)
@ -255,7 +232,7 @@ class RosterWindow(Adw.ApplicationWindow):
remaining_tabs = len(self.page_to_tab) remaining_tabs = len(self.page_to_tab)
if remaining_tabs == 0: if remaining_tabs == 0:
# Schedule creating exactly one new tab # Schedule creating exactly one new tab
GLib.timeout_add(DELAY_TAB_CREATION_MS, self._create_new_tab_once) GLib.timeout_add(50, self._create_new_tab_once)
# Close the page # Close the page
tab_view.close_page_finish(page, True) tab_view.close_page_finish(page, True)
@ -281,7 +258,7 @@ class RosterWindow(Adw.ApplicationWindow):
return False # Allow close return False # Allow close
def _create_new_tab_once(self) -> bool: def _create_new_tab_once(self):
"""Create a new tab (one-time callback).""" """Create a new tab (one-time callback)."""
# Only create if there really are no tabs # Only create if there really are no tabs
if self.tab_view.get_n_pages() == 0: if self.tab_view.get_n_pages() == 0:
@ -289,7 +266,7 @@ class RosterWindow(Adw.ApplicationWindow):
return False # Don't repeat return False # Don't repeat
def _is_empty_new_request_tab(self) -> bool: def _is_empty_new_request_tab(self):
"""Check if current tab is an empty 'New Request' tab.""" """Check if current tab is an empty 'New Request' tab."""
if not self.current_tab_id: if not self.current_tab_id:
return False return False
@ -311,25 +288,22 @@ class RosterWindow(Adw.ApplicationWindow):
# Check if it's empty # Check if it's empty
request = widget.get_request() request = widget.get_request()
# Consider headers empty if they're either empty or only contain default headers
from .models import HttpRequest
has_only_default_headers = request.headers == HttpRequest.default_headers()
is_empty = ( is_empty = (
not request.url.strip() and not request.url.strip() and
not request.body.strip() and not request.body.strip() and
(not request.headers or has_only_default_headers) not request.headers
) )
return is_empty return is_empty
def _find_tab_by_saved_request_id(self, saved_request_id) -> Optional[RequestTab]: def _find_tab_by_saved_request_id(self, saved_request_id):
"""Find a tab by its saved_request_id. Returns None if not found.""" """Find a tab by its saved_request_id. Returns None if not found."""
for tab in self.tab_manager.tabs: for tab in self.tab_manager.tabs:
if tab.saved_request_id == saved_request_id: if tab.saved_request_id == saved_request_id:
return tab return tab
return None return None
def _generate_copy_name(self, base_name: str) -> str: def _generate_copy_name(self, base_name):
"""Generate a unique copy name like 'ReqA (copy)', 'ReqA (copy 2)', etc.""" """Generate a unique copy name like 'ReqA (copy)', 'ReqA (copy 2)', etc."""
# Check if "Name (copy)" exists # Check if "Name (copy)" exists
existing_names = {tab.name for tab in self.tab_manager.tabs} existing_names = {tab.name for tab in self.tab_manager.tabs}
@ -346,11 +320,11 @@ class RosterWindow(Adw.ApplicationWindow):
return f"{base_name} (copy {counter})" return f"{base_name} (copy {counter})"
def _create_new_tab(self, name="New Request", request=None, saved_request_id=None, response=None, def _create_new_tab(self, name="New Request", request=None, saved_request_id=None, response=None,
project_id=None, selected_environment_id=None, scripts=None) -> str: project_id=None, selected_environment_id=None, scripts=None):
"""Create a new tab with RequestTabWidget.""" """Create a new tab with RequestTabWidget."""
# Create tab in tab manager # Create tab in tab manager
if not request: if not request:
request = HttpRequest(method="GET", url="", headers=HttpRequest.default_headers(), body="", syntax="RAW") request = HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW")
tab = self.tab_manager.create_tab(name, request, saved_request_id, response, tab = self.tab_manager.create_tab(name, request, saved_request_id, response,
project_id, selected_environment_id, scripts) project_id, selected_environment_id, scripts)
@ -420,7 +394,7 @@ class RosterWindow(Adw.ApplicationWindow):
# Remove star from title # Remove star from title
page.set_title(tab.name) page.set_title(tab.name)
def _switch_to_tab(self, tab_id: str) -> None: def _switch_to_tab(self, tab_id):
"""Switch to a tab by its ID.""" """Switch to a tab by its ID."""
# Find the page for this tab # Find the page for this tab
for page, tab in self.page_to_tab.items(): for page, tab in self.page_to_tab.items():
@ -428,34 +402,17 @@ class RosterWindow(Adw.ApplicationWindow):
self.tab_view.set_selected_page(page) self.tab_view.set_selected_page(page)
return return
def _close_current_tab(self) -> None: def _close_current_tab(self):
"""Close the currently active tab.""" """Close the currently active tab."""
page = self.tab_view.get_selected_page() page = self.tab_view.get_selected_page()
if page: if page:
self.tab_view.close_page(page) self.tab_view.close_page(page)
def _focus_url_field(self) -> None:
"""Focus the URL field in the current tab."""
page = self.tab_view.get_selected_page()
if page:
widget = self.page_to_widget.get(page)
if widget and hasattr(widget, 'url_entry'):
widget.url_entry.grab_focus()
def _send_current_request(self) -> None:
"""Send the request from the current tab."""
page = self.tab_view.get_selected_page()
if page:
widget = self.page_to_widget.get(page)
if widget and hasattr(widget, 'send_button'):
# Trigger the send button click
self._on_send_clicked(widget)
def _on_new_request_clicked(self, button): def _on_new_request_clicked(self, button):
"""Handle New Request button click.""" """Handle New Request button click."""
self._create_new_tab() self._create_new_tab()
def _create_actions(self) -> None: def _create_actions(self):
"""Create window-level actions.""" """Create window-level actions."""
# New tab shortcut (Ctrl+T) # New tab shortcut (Ctrl+T)
action = Gio.SimpleAction.new("new-tab", None) action = Gio.SimpleAction.new("new-tab", None)
@ -475,178 +432,93 @@ class RosterWindow(Adw.ApplicationWindow):
self.add_action(action) self.add_action(action)
self.get_application().set_accels_for_action("win.save-request", ["<Control>s"]) self.get_application().set_accels_for_action("win.save-request", ["<Control>s"])
# Focus URL field shortcut (Ctrl+L)
action = Gio.SimpleAction.new("focus-url", None)
action.connect("activate", lambda a, p: self._focus_url_field())
self.add_action(action)
self.get_application().set_accels_for_action("win.focus-url", ["<Control>l"])
# Send request shortcut (Ctrl+Return)
action = Gio.SimpleAction.new("send-request", None)
action.connect("activate", lambda a, p: self._send_current_request())
self.add_action(action)
self.get_application().set_accels_for_action("win.send-request", ["<Control>Return"])
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
if hasattr(widget, '_clear_preprocessing_results'):
widget._clear_preprocessing_results()
# Validate URL # Validate URL
request = widget.get_request() request = widget.get_request()
if not request.url.strip(): if not request.url.strip():
self._show_toast("Please enter a URL") self._show_toast("Please enter a URL")
return return
# Execute preprocessing script (if exists) # Apply variable substitution if environment is selected
scripts = widget.get_scripts() substituted_request = request
modified_request = request if widget.selected_environment_id:
env = widget.get_selected_environment()
if scripts and scripts.preprocessing.strip(): if env:
from .script_executor import ScriptContext from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(request, env)
preprocessing_result = self._execute_preprocessing( # Log undefined variables for debugging
widget, scripts.preprocessing, request if undefined:
) print(f"Warning: Undefined variables in request: {', '.join(undefined)}")
# Display preprocessing results
if hasattr(widget, 'display_preprocessing_results'):
widget.display_preprocessing_results(preprocessing_result)
# Handle errors - DON'T send request if preprocessing failed
if not preprocessing_result.success:
self._show_toast(f"Preprocessing error: {preprocessing_result.error}")
return # Early return, don't send request
# Create context for variable updates
context = None
if widget.project_id and widget.selected_environment_id:
context = ScriptContext(
project_id=widget.project_id,
environment_id=widget.selected_environment_id,
project_manager=self.project_manager
)
# Process variable updates from preprocessing
self._process_variable_updates(preprocessing_result, widget, context)
# Use modified request (if returned)
if preprocessing_result.modified_request:
modified_request = preprocessing_result.modified_request
# Disable send button during request # Disable send button during request
widget.send_button.set_sensitive(False) widget.send_button.set_sensitive(False)
def proceed_with_request(env): # Execute async
"""Continue with request after environment is loaded.""" def callback(response, error, user_data):
# Apply variable substitution to (possibly modified) request """Callback runs on main thread."""
substituted_request = modified_request # Re-enable send button
if env: widget.send_button.set_sensitive(True)
from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(modified_request, env)
# Log undefined variables for debugging
if undefined:
logger.warning(f"Undefined variables in request: {', '.join(undefined)}")
# Execute async # Update tab with response
def callback(response, error, user_data): if response:
"""Callback runs on main thread.""" widget.display_response(response)
# Re-enable send button
widget.send_button.set_sensitive(True)
# Update tab with response # Execute postprocessing script if exists
if response: scripts = widget.get_scripts()
widget.display_response(response) if scripts and scripts.postprocessing.strip():
from .script_executor import ScriptExecutor, ScriptContext
# Execute postprocessing script if exists # Create context for variable updates
scripts = widget.get_scripts() context = None
if scripts and scripts.postprocessing.strip(): if widget.project_id and widget.selected_environment_id:
from .script_executor import ScriptExecutor, ScriptContext context = ScriptContext(
project_id=widget.project_id,
# Create context for variable updates environment_id=widget.selected_environment_id,
context = None project_manager=self.project_manager
if widget.project_id and widget.selected_environment_id:
context = ScriptContext(
project_id=widget.project_id,
environment_id=widget.selected_environment_id,
project_manager=self.project_manager
)
# Execute script with context
script_result = ScriptExecutor.execute_postprocessing_script(
scripts.postprocessing,
response,
context
) )
# Display script results # Execute script with context
widget.display_script_results(script_result) script_result = ScriptExecutor.execute_postprocessing_script(
scripts.postprocessing,
response,
context
)
# Process variable updates # Display script results
self._process_variable_updates(script_result, widget, context) widget.display_script_results(script_result)
else:
widget._clear_script_results() # Process variable updates
self._process_variable_updates(script_result, widget, context)
else: else:
widget.display_error(error or "Unknown error")
widget._clear_script_results() widget._clear_script_results()
else:
widget.display_error(error or "Unknown error")
widget._clear_script_results()
# Save response to tab # Save response to tab
page = self.tab_view.get_selected_page() page = self.tab_view.get_selected_page()
if page: if page:
tab = self.page_to_tab.get(page) tab = self.page_to_tab.get(page)
if tab: if tab:
tab.response = response tab.response = response
# Create history entry with redacted sensitive variables # Create history entry (save the substituted request with actual values)
# Determine which request to save to history entry = HistoryEntry(
request_for_history = modified_request timestamp=datetime.now().isoformat(),
has_redacted = False request=substituted_request,
response=response,
error=error
)
self.history_manager.add_entry(entry)
if env and widget.project_id: # Refresh history panel to show new entry
# Get the project's sensitive variables list self._load_history()
projects = self.project_manager.load_projects()
sensitive_vars = []
for p in projects:
if p.id == widget.project_id:
sensitive_vars = p.sensitive_variables
break
# Create redacted request (only non-sensitive variables substituted) self.http_client.execute_request_async(substituted_request, callback, None)
if sensitive_vars:
from .variable_substitution import VariableSubstitution
request_for_history, _, has_redacted = VariableSubstitution.substitute_request_excluding_sensitive(
modified_request, env, sensitive_vars
)
else:
# No sensitive variables defined, use fully substituted request
request_for_history = substituted_request
elif env:
# Environment loaded but no project - use fully substituted request
request_for_history = substituted_request
entry = HistoryEntry(
timestamp=datetime.now().isoformat(),
request=request_for_history,
response=response,
error=error,
has_redacted_variables=has_redacted
)
self.history_manager.add_entry(entry)
# Refresh history panel to show new entry
self._load_history()
self.http_client.execute_request_async(substituted_request, callback, None)
# Fetch environment asynchronously then proceed
if widget.selected_environment_id:
widget.get_selected_environment(proceed_with_request)
else:
proceed_with_request(None)
# History and Project Management # History and Project Management
def _load_history(self) -> None: def _load_history(self):
"""Load history from file and populate list.""" """Load history from file and populate list."""
# Clear existing history items # Clear existing history items
while True: while True:
@ -682,7 +554,7 @@ class RosterWindow(Adw.ApplicationWindow):
# Generate name from method and URL # Generate name from method and URL
url_parts = request.url.split('/') url_parts = request.url.split('/')
url_name = url_parts[-1] if url_parts else request.url url_name = url_parts[-1] if url_parts else request.url
name = f"{request.method} {url_name[:DISPLAY_URL_NAME_MAX_LENGTH]}" if url_name else f"{request.method} Request" name = f"{request.method} {url_name[:30]}" if url_name else f"{request.method} Request"
# Check if current tab is an empty "New Request" # Check if current tab is an empty "New Request"
if self._is_empty_new_request_tab(): if self._is_empty_new_request_tab():
@ -724,36 +596,6 @@ class RosterWindow(Adw.ApplicationWindow):
self._show_toast("Request loaded from history") self._show_toast("Request loaded from history")
def _execute_preprocessing(self, widget, script_code, request):
"""
Execute preprocessing script and return result.
Args:
widget: RequestTabWidget instance
script_code: JavaScript preprocessing script
request: HttpRequest to be preprocessed
Returns:
ScriptResult with modified request and variable updates
"""
from .script_executor import ScriptExecutor, ScriptContext
# Create context for variable access
context = None
if widget.project_id and widget.selected_environment_id:
context = ScriptContext(
project_id=widget.project_id,
environment_id=widget.selected_environment_id,
project_manager=self.project_manager
)
# Execute preprocessing
return ScriptExecutor.execute_preprocessing_script(
script_code,
request,
context
)
def _process_variable_updates(self, script_result, widget, context): def _process_variable_updates(self, script_result, widget, context):
""" """
Process variable updates from postprocessing script. Process variable updates from postprocessing script.
@ -833,72 +675,19 @@ class RosterWindow(Adw.ApplicationWindow):
vars_list = ', '.join(updated_vars) vars_list = ', '.join(updated_vars)
self._show_toast(f"Updated {len(updated_vars)} variable(s) in {env_name}: {vars_list}") self._show_toast(f"Updated {len(updated_vars)} variable(s) in {env_name}: {vars_list}")
def _show_toast(self, message: str) -> None: def _show_toast(self, message):
"""Show a toast notification.""" """Show a toast notification."""
toast = Adw.Toast() toast = Adw.Toast()
toast.set_title(message) toast.set_title(message)
toast.set_timeout(UI_TOAST_TIMEOUT_SECONDS) toast.set_timeout(3)
self.toast_overlay.add_toast(toast)
# Get the toast overlay (we need to add one)
# For now, just print to console
print(f"Toast: {message}")
# Project Management Methods # Project Management Methods
def _validate_project_name(self, name: str) -> tuple[bool, str]: def _load_projects(self):
"""
Validate project name.
Args:
name: The project name to validate
Returns:
Tuple of (is_valid, error_message)
"""
# Check if empty
if not name or not name.strip():
return False, "Project name cannot be empty"
# Check length
if len(name) > PROJECT_NAME_MAX_LENGTH:
return False, f"Project name is too long (max {PROJECT_NAME_MAX_LENGTH} characters)"
# Check for invalid characters (file system unsafe characters)
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0']
for char in invalid_chars:
if char in name:
return False, f"Project name cannot contain '{char}'"
# Check if it's only whitespace
if name.strip() == '':
return False, "Project name cannot be only whitespace"
return True, ""
def _validate_request_name(self, name: str) -> tuple[bool, str]:
"""
Validate request name.
Args:
name: The request name to validate
Returns:
Tuple of (is_valid, error_message)
"""
# Check if empty
if not name or not name.strip():
return False, "Request name cannot be empty"
# Check length
if len(name) > REQUEST_NAME_MAX_LENGTH:
return False, f"Request name is too long (max {REQUEST_NAME_MAX_LENGTH} characters)"
# Check for invalid characters (file system unsafe characters)
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0']
for char in invalid_chars:
if char in name:
return False, f"Request name cannot contain '{char}'"
return True, ""
def _load_projects(self) -> None:
"""Load and display projects.""" """Load and display projects."""
# Clear existing # Clear existing
while child := self.projects_listbox.get_first_child(): while child := self.projects_listbox.get_first_child():
@ -936,13 +725,9 @@ class RosterWindow(Adw.ApplicationWindow):
def on_response(dlg, response): def on_response(dlg, response):
if response == "create": if response == "create":
name = entry.get_text().strip() name = entry.get_text().strip()
is_valid, error_msg = self._validate_project_name(name) if name:
if is_valid:
self.project_manager.add_project(name) self.project_manager.add_project(name)
self._load_projects() self._load_projects()
self._show_toast(f"Project '{name}' created")
else:
self._show_toast(error_msg)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@ -992,13 +777,9 @@ class RosterWindow(Adw.ApplicationWindow):
if response == "save": if response == "save":
new_name = entry.get_text().strip() new_name = entry.get_text().strip()
new_icon = icon_button.selected_icon new_icon = icon_button.selected_icon
is_valid, error_msg = self._validate_project_name(new_name) if new_name:
if is_valid:
self.project_manager.update_project(project.id, new_name, new_icon) self.project_manager.update_project(project.id, new_name, new_icon)
self._load_projects() self._load_projects()
self._show_toast(f"Project updated")
else:
self._show_toast(error_msg)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@ -1024,8 +805,7 @@ class RosterWindow(Adw.ApplicationWindow):
def _on_manage_environments(self, widget, project): def _on_manage_environments(self, widget, project):
"""Show environments management dialog.""" """Show environments management dialog."""
# Get latest project data (includes variables created by scripts) # Reload project from disk to get latest data (including variables created by scripts)
# Note: load_projects() uses cached data that's kept up-to-date by save operations
projects = self.project_manager.load_projects() projects = self.project_manager.load_projects()
fresh_project = None fresh_project = None
for p in projects: for p in projects:
@ -1086,7 +866,7 @@ class RosterWindow(Adw.ApplicationWindow):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
# Check if current tab has a saved request or project (for pre-filling) # Check if current tab has a saved request (for pre-filling)
current_tab = None current_tab = None
preselect_project_index = 0 preselect_project_index = 0
prefill_name = "" prefill_name = ""
@ -1101,13 +881,7 @@ class RosterWindow(Adw.ApplicationWindow):
preselect_project_index = i preselect_project_index = i
prefill_name = current_tab.name prefill_name = current_tab.name
break break
elif current_tab.project_id: elif current_tab.name and current_tab.name != "New Request":
# Tab associated with a project (e.g., from "Add Request")
for i, p in enumerate(projects):
if p.id == current_tab.project_id:
preselect_project_index = i
break
if current_tab.name and current_tab.name != "New Request":
# Copy tab or named unsaved tab - prefill with tab name # Copy tab or named unsaved tab - prefill with tab name
prefill_name = current_tab.name prefill_name = current_tab.name
@ -1137,8 +911,7 @@ class RosterWindow(Adw.ApplicationWindow):
def on_response(dlg, response): def on_response(dlg, response):
if response == "save": if response == "save":
name = entry.get_text().strip() name = entry.get_text().strip()
is_valid, error_msg = self._validate_request_name(name) if name:
if is_valid:
selected = dropdown.get_selected() selected = dropdown.get_selected()
project = projects[selected] project = projects[selected]
# Check for duplicate name # Check for duplicate name
@ -1154,53 +927,10 @@ class RosterWindow(Adw.ApplicationWindow):
# Clear modified flag on current tab # Clear modified flag on current tab
self._mark_tab_as_saved(saved_request.id, name, request, scripts) self._mark_tab_as_saved(saved_request.id, name, request, scripts)
else:
self._show_toast(error_msg)
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) dialog.present(self)
@Gtk.Template.Callback()
def on_export_request_clicked(self, button):
"""Handle Export button click - export request as cURL command."""
# Get current tab
page = self.tab_view.get_selected_page()
if not page:
self._show_toast("No active request")
return
widget = self.page_to_widget.get(page)
if not widget:
self._show_toast("No active request")
return
# Get request from widget (raw with {{variables}})
request = widget.get_request()
# Validate URL
if not request.url.strip():
self._show_toast("Cannot export: URL is empty")
return
# Apply variable substitution if environment is selected
substituted_request = request
if widget.selected_environment_id:
env = widget.get_selected_environment()
if env:
from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(request, env)
# Warn about undefined variables
if undefined:
logger.warning(f"Undefined variables in export: {', '.join(undefined)}")
# Show warning toast but continue with export
self._show_toast(f"Warning: {len(undefined)} undefined variable(s)")
# Show export dialog with substituted request
from .export_dialog import ExportDialog
dialog = ExportDialog(substituted_request)
dialog.present(self)
def _mark_tab_as_saved(self, saved_request_id, name, request, scripts=None): def _mark_tab_as_saved(self, saved_request_id, name, request, scripts=None):
"""Mark the current tab as saved (clear modified flag).""" """Mark the current tab as saved (clear modified flag)."""
page = self.tab_view.get_selected_page() page = self.tab_view.get_selected_page()
@ -1271,18 +1001,45 @@ class RosterWindow(Adw.ApplicationWindow):
dialog.present(self) dialog.present(self)
def _on_add_to_project(self, widget, project): def _on_add_to_project(self, widget, project):
"""Create a new empty request tab associated with the project.""" """Save current request to specific project."""
# Get default environment for the project request = self._build_request_from_ui()
default_env_id = project.environments[0].id if project.environments else None
# Create a new tab with project_id set so Save will pre-select this project if not request.url.strip():
self._create_new_tab( self._show_toast("Cannot save: URL is empty")
name="New Request", return
project_id=project.id,
selected_environment_id=default_env_id
)
self._show_toast(f"New request for '{project.name}'") dialog = Adw.AlertDialog()
dialog.set_heading(f"Save to {project.name}")
dialog.set_body("Enter a name for this request:")
entry = Gtk.Entry()
entry.set_placeholder_text("Request name")
dialog.set_extra_child(entry)
dialog.add_response("cancel", "Cancel")
dialog.add_response("save", "Save")
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
def on_response(dlg, response):
if response == "save":
name = entry.get_text().strip()
if name:
# Check for duplicate name
existing = self.project_manager.find_request_by_name(project.id, name)
if existing:
# Show overwrite confirmation
self._show_overwrite_dialog(project, name, existing.id, request)
else:
# No duplicate, save normally
saved_request = self.project_manager.add_request(project.id, name, request)
self._load_projects()
self._show_toast(f"Saved as '{name}'")
# Clear modified flag on current tab
self._mark_tab_as_saved(saved_request.id, name, request)
dialog.connect("response", on_response)
dialog.present(self)
def _on_load_request(self, widget, saved_request): def _on_load_request(self, widget, saved_request):
"""Load saved request - smart loading based on current tab state.""" """Load saved request - smart loading based on current tab state."""