Compare commits

..

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

67 changed files with 690 additions and 4863 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.2.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,9 +5,8 @@
<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">700</property>
<property name="content-height">600</property> <property name="content-height">500</property>
<property name="follows-content-size">false</property>
<child> <child>
<object class="AdwToolbarView"> <object class="AdwToolbarView">
@ -21,20 +20,70 @@
<object class="GtkScrolledWindow"> <object class="GtkScrolledWindow">
<property name="vexpand">true</property> <property name="vexpand">true</property>
<child> <child>
<object class="AdwClamp"> <object class="GtkBox">
<property name="maximum-size">1200</property> <property name="orientation">vertical</property>
<property name="tightening-threshold">800</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>
<property name="spacing">24</property>
<!-- Variables Section -->
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="spacing">12</property>
<child>
<object class="GtkLabel">
<property name="label">Variables</property>
<property name="xalign">0</property>
<property name="hexpand">True</property>
<style>
<class name="title-4"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="add_variable_button">
<property name="icon-name">list-add-symbolic</property>
<property name="tooltip-text">Add variable</property>
<signal name="clicked" handler="on_add_variable_clicked"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkFrame">
<child>
<object class="GtkListBox" id="variables_listbox">
<property name="selection-mode">none</property>
<style>
<class name="boxed-list"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
<!-- Environments Section -->
<child> <child>
<object class="GtkBox"> <object class="GtkBox">
<property name="orientation">vertical</property> <property name="orientation">vertical</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>
<property name="spacing">12</property> <property name="spacing">12</property>
<!-- Header Section -->
<child> <child>
<object class="GtkBox"> <object class="GtkBox">
<property name="orientation">horizontal</property> <property name="orientation">horizontal</property>
@ -53,7 +102,6 @@
<child> <child>
<object class="GtkButton" id="add_environment_button"> <object class="GtkButton" id="add_environment_button">
<property name="label">Add Environment</property>
<property name="icon-name">list-add-symbolic</property> <property name="icon-name">list-add-symbolic</property>
<property name="tooltip-text">Add environment</property> <property name="tooltip-text">Add environment</property>
<signal name="clicked" handler="on_add_environment_clicked"/> <signal name="clicked" handler="on_add_environment_clicked"/>
@ -65,12 +113,11 @@
</object> </object>
</child> </child>
<!-- Table Frame -->
<child> <child>
<object class="GtkFrame"> <object class="GtkFrame">
<child> <child>
<object class="GtkBox" id="table_container"> <object class="GtkListBox" id="environments_listbox">
<property name="orientation">vertical</property> <property name="selection-mode">none</property>
<style> <style>
<class name="boxed-list"/> <class name="boxed-list"/>
</style> </style>

View File

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -21,111 +20,58 @@
from gi.repository import Adw, Gtk, GObject from gi.repository import Adw, Gtk, GObject
from .widgets.variable_row import VariableRow from .widgets.variable_row import VariableRow
from .widgets.environment_row import EnvironmentRow from .widgets.environment_row import EnvironmentRow
from .widgets.environment_header_row import EnvironmentHeaderRow
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."""
__gtype_name__ = 'EnvironmentsDialog' __gtype_name__ = 'EnvironmentsDialog'
table_container = Gtk.Template.Child() variables_listbox = Gtk.Template.Child()
add_variable_button = Gtk.Template.Child()
environments_listbox = Gtk.Template.Child()
add_environment_button = Gtk.Template.Child() add_environment_button = Gtk.Template.Child()
__gsignals__ = { __gsignals__ = {
'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()), 'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
} }
def __init__(self, project, project_manager, recently_updated_variables=None): def __init__(self, project, project_manager):
super().__init__() super().__init__()
self.project = project self.project = project
self.project_manager = project_manager self.project_manager = project_manager
self.recently_updated_variables = recently_updated_variables or {} self._populate_variables()
self.size_group = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL) self._populate_environments()
self.header_row = None
self.data_rows = []
self._populate_table()
def _populate_table(self): def _populate_variables(self):
"""Populate the transposed environment-variable table.""" """Populate variables list."""
# Clear existing # Clear existing
while child := self.table_container.get_first_child(): while child := self.variables_listbox.get_first_child():
self.table_container.remove(child) self.variables_listbox.remove(child)
self.data_rows = [] # Add variables
# Create header row
self.header_row = EnvironmentHeaderRow(
self.project.environments,
self.size_group
)
self.header_row.connect('environment-edit-requested',
self._on_environment_edit)
self.header_row.connect('environment-delete-requested',
self._on_environment_delete)
self.table_container.append(self.header_row)
# Add separator
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
self.table_container.append(separator)
# Create data rows for each variable
for var_name in self.project.variable_names: for var_name in self.project.variable_names:
# Check if variable was recently updated row = VariableRow(var_name)
update_timestamp = self.recently_updated_variables.get(var_name) row.connect('remove-requested', self._on_variable_remove, var_name)
row.connect('changed', self._on_variable_changed, var_name, row)
self.variables_listbox.append(row)
row = VariableDataRow( def _populate_environments(self):
var_name, """Populate environments list."""
self.project.environments, # Clear existing
self.size_group, while child := self.environments_listbox.get_first_child():
self.project, self.environments_listbox.remove(child)
self.project_manager,
update_timestamp=update_timestamp
)
row.connect('variable-changed',
self._on_variable_changed, var_name, row)
row.connect('variable-delete-requested',
self._on_variable_remove, var_name)
row.connect('value-changed',
self._on_value_changed, var_name)
row.connect('sensitivity-changed',
self._on_sensitivity_changed, var_name)
self.table_container.append(row) # Add environments
self.data_rows.append(row) for env in self.project.environments:
row = EnvironmentRow(env, self.project.variable_names)
# Add "Add Variable" button row at the bottom row.connect('edit-requested', self._on_environment_edit, env)
add_var_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) row.connect('delete-requested', self._on_environment_delete, env)
add_var_row.set_margin_start(12) row.connect('value-changed', self._on_environment_value_changed, env)
add_var_row.set_margin_end(12) self.environments_listbox.append(row)
add_var_row.set_margin_top(6)
add_var_row.set_margin_bottom(6)
# Variable cell with add button
var_cell = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
var_cell.set_size_request(150, -1)
if self.size_group:
self.size_group.add_widget(var_cell)
add_variable_button = Gtk.Button()
add_variable_button.set_icon_name("list-add-symbolic")
add_variable_button.set_tooltip_text("Add variable")
add_variable_button.connect('clicked', self.on_add_variable_clicked)
add_variable_button.add_css_class("flat")
add_variable_button.add_css_class("circular")
var_cell.append(add_variable_button)
add_var_row.append(var_cell)
# Empty space for value columns
empty_space = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
empty_space.set_hexpand(True)
add_var_row.append(empty_space)
self.table_container.append(add_var_row)
@Gtk.Template.Callback()
def on_add_variable_clicked(self, button): def on_add_variable_clicked(self, button):
"""Add new variable.""" """Add new variable."""
dialog = Adw.AlertDialog() dialog = Adw.AlertDialog()
@ -134,7 +80,6 @@ class EnvironmentsDialog(Adw.Dialog):
entry = Gtk.Entry() entry = Gtk.Entry()
entry.set_placeholder_text("Variable name") entry.set_placeholder_text("Variable name")
entry.set_activates_default(True)
dialog.set_extra_child(entry) dialog.set_extra_child(entry)
dialog.add_response("cancel", "Cancel") dialog.add_response("cancel", "Cancel")
@ -150,7 +95,8 @@ class EnvironmentsDialog(Adw.Dialog):
self.project_manager.add_variable(self.project.id, name) self.project_manager.add_variable(self.project.id, name)
# Reload project data # Reload project data
self._reload_project() self._reload_project()
self._populate_table() self._populate_variables()
self._populate_environments()
self.emit('environments-updated') self.emit('environments-updated')
dialog.connect("response", on_response) dialog.connect("response", on_response)
@ -169,12 +115,11 @@ 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_variables()
self.emit('environments-updated') self._populate_environments()
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 +128,11 @@ 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_variables()
self.emit('environments-updated') self._populate_environments()
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):
@ -199,7 +143,6 @@ class EnvironmentsDialog(Adw.Dialog):
entry = Gtk.Entry() entry = Gtk.Entry()
entry.set_placeholder_text("Environment name (e.g., Production)") entry.set_placeholder_text("Environment name (e.g., Production)")
entry.set_activates_default(True)
dialog.set_extra_child(entry) dialog.set_extra_child(entry)
dialog.add_response("cancel", "Cancel") dialog.add_response("cancel", "Cancel")
@ -214,7 +157,7 @@ class EnvironmentsDialog(Adw.Dialog):
if name: if name:
self.project_manager.add_environment(self.project.id, name) self.project_manager.add_environment(self.project.id, name)
self._reload_project() self._reload_project()
self._populate_table() self._populate_environments()
self.emit('environments-updated') self.emit('environments-updated')
dialog.connect("response", on_response) dialog.connect("response", on_response)
@ -228,14 +171,11 @@ class EnvironmentsDialog(Adw.Dialog):
entry = Gtk.Entry() entry = Gtk.Entry()
entry.set_text(environment.name) entry.set_text(environment.name)
entry.set_activates_default(True)
dialog.set_extra_child(entry) dialog.set_extra_child(entry)
dialog.add_response("cancel", "Cancel") dialog.add_response("cancel", "Cancel")
dialog.add_response("save", "Save") dialog.add_response("save", "Save")
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
dialog.set_default_response("save")
dialog.set_close_response("cancel")
def on_response(dlg, response): def on_response(dlg, response):
if response == "save": if response == "save":
@ -243,7 +183,7 @@ class EnvironmentsDialog(Adw.Dialog):
if new_name: if new_name:
self.project_manager.update_environment(self.project.id, environment.id, name=new_name) self.project_manager.update_environment(self.project.id, environment.id, name=new_name)
self._reload_project() self._reload_project()
self._populate_table() self._populate_environments()
self.emit('environments-updated') self.emit('environments-updated')
dialog.connect("response", on_response) dialog.connect("response", on_response)
@ -271,38 +211,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_environments()
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_environment_value_changed(self, widget, var_name, value, environment):
"""Handle value change in table cell.""" """Handle environment variable value change."""
# Note: value is already stored by VariableDataRow using set_variable_value self.project_manager.update_environment_variable(self.project.id, environment.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

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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>
@ -69,6 +66,20 @@
</child> </child>
</object> </object>
</child> </child>
<!-- Save Current Request Button -->
<child>
<object class="GtkButton" id="save_request_button">
<property name="label">Save Current Request</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-bottom">12</property>
<signal name="clicked" handler="on_save_request_clicked"/>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
</object> </object>
</property> </property>
</object> </object>
@ -85,29 +96,6 @@
<property name="show-start-title-buttons">False</property> <property name="show-start-title-buttons">False</property>
<property name="show-end-title-buttons">False</property> <property name="show-end-title-buttons">False</property>
<!-- Left side buttons -->
<child type="start">
<object class="GtkButton" id="save_request_button">
<property name="icon-name">document-save-symbolic</property>
<property name="tooltip-text">Save Current Request (Ctrl+S)</property>
<signal name="clicked" handler="on_save_request_clicked"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
<child type="start">
<object class="GtkButton" id="export_request_button">
<property name="icon-name">export-symbolic</property>
<property name="tooltip-text">Export as cURL</property>
<signal name="clicked" handler="on_export_request_clicked"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
<!-- Right side buttons --> <!-- Right side buttons -->
<child type="end"> <child type="end">
<object class="GtkBox"> <object class="GtkBox">
@ -160,8 +148,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 +163,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 +190,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 +208,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,29 +35,16 @@ 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',
] ]
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',
@ -67,9 +54,6 @@ widgets_sources = [
'widgets/request_item.py', 'widgets/request_item.py',
'widgets/variable_row.py', 'widgets/variable_row.py',
'widgets/environment_row.py', 'widgets/environment_row.py',
'widgets/environment_column_header.py',
'widgets/environment_header_row.py',
'widgets/variable_data_row.py',
] ]
install_data(widgets_sources, install_dir: moduledir / 'widgets') install_data(widgets_sources, install_dir: moduledir / 'widgets')

View File

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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)
) )
@ -123,7 +98,6 @@ class SavedRequest:
request: HttpRequest request: HttpRequest
created_at: str # ISO format created_at: str # ISO format
modified_at: str # ISO format modified_at: str # ISO format
scripts: Optional['Scripts'] = None # Scripts for preprocessing and postprocessing
def to_dict(self): def to_dict(self):
"""Convert to dictionary for JSON serialization.""" """Convert to dictionary for JSON serialization."""
@ -132,8 +106,7 @@ class SavedRequest:
'name': self.name, 'name': self.name,
'request': self.request.to_dict(), 'request': self.request.to_dict(),
'created_at': self.created_at, 'created_at': self.created_at,
'modified_at': self.modified_at, 'modified_at': self.modified_at
'scripts': self.scripts.to_dict() if self.scripts else None
} }
@classmethod @classmethod
@ -144,8 +117,7 @@ class SavedRequest:
name=data['name'], name=data['name'],
request=HttpRequest.from_dict(data['request']), request=HttpRequest.from_dict(data['request']),
created_at=data['created_at'], created_at=data['created_at'],
modified_at=data['modified_at'], modified_at=data['modified_at']
scripts=Scripts.from_dict(data['scripts']) if data.get('scripts') else None
) )
@ -187,7 +159,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 +166,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 +176,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 +189,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
) )
@ -238,7 +205,6 @@ class RequestTab:
original_request: Optional[HttpRequest] = None # For change detection original_request: Optional[HttpRequest] = None # For change detection
project_id: Optional[str] = None # Project association for environment variables project_id: Optional[str] = None # Project association for environment variables
selected_environment_id: Optional[str] = None # Selected environment for variable substitution selected_environment_id: Optional[str] = None # Selected environment for variable substitution
scripts: Optional['Scripts'] = None # Scripts for preprocessing and postprocessing
def is_modified(self) -> bool: def is_modified(self) -> bool:
"""Check if current request differs from original.""" """Check if current request differs from original."""
@ -265,8 +231,7 @@ class RequestTab:
'modified': self.modified, 'modified': self.modified,
'original_request': self.original_request.to_dict() if self.original_request else None, 'original_request': self.original_request.to_dict() if self.original_request else None,
'project_id': self.project_id, 'project_id': self.project_id,
'selected_environment_id': self.selected_environment_id, 'selected_environment_id': self.selected_environment_id
'scripts': self.scripts.to_dict() if self.scripts else None
} }
@classmethod @classmethod
@ -281,28 +246,5 @@ class RequestTab:
modified=data.get('modified', False), modified=data.get('modified', False),
original_request=HttpRequest.from_dict(data['original_request']) if data.get('original_request') else None, original_request=HttpRequest.from_dict(data['original_request']) if data.get('original_request') else None,
project_id=data.get('project_id'), project_id=data.get('project_id'),
selected_environment_id=data.get('selected_environment_id'), selected_environment_id=data.get('selected_environment_id')
scripts=Scripts.from_dict(data['scripts']) if data.get('scripts') else None
)
@dataclass
class Scripts:
"""Scripts for request preprocessing and postprocessing."""
preprocessing: str = ""
postprocessing: str = ""
def to_dict(self):
"""Convert to dictionary for JSON serialization."""
return {
'preprocessing': self.preprocessing,
'postprocessing': self.postprocessing
}
@classmethod
def from_dict(cls, data):
"""Create instance from dictionary."""
return cls(
preprocessing=data.get('preprocessing', ''),
postprocessing=data.get('postprocessing', '')
) )

View File

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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,24 +105,13 @@ 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 add_request(self, project_id: str, name: str, request: HttpRequest) -> SavedRequest:
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:
"""Add request to a project.""" """Add request to a project."""
projects = self.load_projects() projects = self.load_projects()
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
@ -158,8 +120,7 @@ class ProjectManager:
name=name, name=name,
request=request, request=request,
created_at=now, created_at=now,
modified_at=now, modified_at=now
scripts=scripts
) )
for p in projects: for p in projects:
if p.id == project_id: if p.id == project_id:
@ -179,7 +140,7 @@ class ProjectManager:
break break
return None return None
def update_request(self, project_id: str, request_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest: def update_request(self, project_id: str, request_id: str, name: str, request: HttpRequest) -> SavedRequest:
"""Update an existing request.""" """Update an existing request."""
projects = self.load_projects() projects = self.load_projects()
updated_request = None updated_request = None
@ -189,7 +150,6 @@ class ProjectManager:
if req.id == request_id: if req.id == request_id:
req.name = name req.name = name
req.request = request req.request = request
req.scripts = scripts
req.modified_at = datetime.now(timezone.utc).isoformat() req.modified_at = datetime.now(timezone.utc).isoformat()
updated_request = req updated_request = req
break break
@ -244,27 +204,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 +226,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()
@ -364,318 +267,3 @@ class ProjectManager:
break break
break break
self.save_projects(projects) self.save_projects(projects)
def batch_update_environment_variables(self, project_id: str, env_id: str, variables: dict):
"""
Update multiple variables in an environment in one operation.
Args:
project_id: Project ID
env_id: Environment ID
variables: Dict of variable_name -> value pairs to update
"""
projects = self.load_projects()
for p in projects:
if p.id == project_id:
for env in p.environments:
if env.id == env_id:
# Update all variables
for var_name, var_value in variables.items():
env.variables[var_name] = var_value
break
break
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)

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,16 @@
<?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>
<file preprocess="xml-stripblanks">widgets/request-item.ui</file> <file preprocess="xml-stripblanks">widgets/request-item.ui</file>
<file preprocess="xml-stripblanks">widgets/variable-row.ui</file> <file preprocess="xml-stripblanks">widgets/variable-row.ui</file>
<file preprocess="xml-stripblanks">widgets/environment-row.ui</file> <file preprocess="xml-stripblanks">widgets/environment-row.ui</file>
<file preprocess="xml-stripblanks">widgets/environment-column-header.ui</file>
<file preprocess="xml-stripblanks">widgets/environment-header-row.ui</file>
<file preprocess="xml-stripblanks">widgets/variable-data-row.ui</file>
</gresource> </gresource>
</gresources> </gresources>

View File

@ -1,440 +0,0 @@
# script_executor.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 subprocess
import json
import tempfile
import re
from pathlib import Path
from typing import Tuple, Optional, Dict, List, TYPE_CHECKING
from dataclasses import dataclass, field
from .constants import SCRIPT_EXECUTION_TIMEOUT_SECONDS
if TYPE_CHECKING:
from .project_manager import ProjectManager
from .models import HttpRequest
@dataclass
class ScriptResult:
"""Result of script execution."""
success: bool
output: str # console.log output or error message
error: Optional[str] = None
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)
modified_request: Optional['HttpRequest'] = None # Modified request from preprocessing
@dataclass
class ScriptContext:
"""Context passed to script executor for variable updates."""
project_id: Optional[str]
environment_id: Optional[str]
project_manager: 'ProjectManager'
class ScriptExecutor:
"""Executes JavaScript code using gjs (GNOME JavaScript)."""
TIMEOUT_SECONDS = SCRIPT_EXECUTION_TIMEOUT_SECONDS
@staticmethod
def execute_postprocessing_script(
script_code: str,
response,
context: Optional[ScriptContext] = None
) -> ScriptResult:
"""
Execute postprocessing script with response object.
Args:
script_code: JavaScript code to execute
response: HttpResponse object
context: Optional context for variable updates
Returns:
ScriptResult with output, variable updates, or error
"""
if not script_code.strip():
return ScriptResult(success=True, output="")
# Create response object for JavaScript
response_obj = ScriptExecutor._create_response_object(response)
# Build complete script with context
script_with_context = f"""
const response = {json.dumps(response_obj)};
// Roster API for variable management
const roster = {{
__variables: {{}},
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]);
}}
}}
}}
}};
// 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: console logs + separator + variables JSON
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 variables
console_output = ""
variable_updates = {}
warnings = []
if returncode == 0:
# Split output into console logs and variables JSON
parts = output.split('___ROSTER_VARIABLES___')
console_output = parts[0].strip()
if len(parts) > 1:
try:
variables_json = 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
)
else:
error_msg = error.strip() if error else "Script execution failed"
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
def _create_response_object(response) -> dict:
"""Convert HttpResponse to JavaScript-compatible dict."""
# Parse headers text into dict
headers = {}
if response.headers:
for line in response.headers.split('\n')[1:]: # Skip status line
line = line.strip()
if ':' in line:
key, value = line.split(':', 1)
headers[key.strip()] = value.strip()
return {
'body': response.body,
'headers': headers,
'statusCode': response.status_code,
'statusText': response.status_text,
'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
def _is_valid_variable_name(name: str) -> bool:
"""
Validate variable name (alphanumeric + underscore).
Args:
name: Variable name to validate
Returns:
True if valid, False otherwise
"""
return bool(re.match(r'^\w+$', name))
@staticmethod
def _run_gjs_script(script_code: str, timeout: int = 5) -> Tuple[str, str, int]:
"""
Run JavaScript code using gjs.
Args:
script_code: Complete JavaScript code
timeout: Execution timeout in seconds
Returns:
Tuple of (stdout, stderr, returncode)
"""
try:
# Write script to temporary file
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
f.write(script_code)
script_path = f.name
try:
# Execute with gjs
result = subprocess.run(
['gjs', script_path],
capture_output=True,
text=True,
timeout=timeout
)
return result.stdout, result.stderr, result.returncode
finally:
# Clean up temp file
Path(script_path).unlink(missing_ok=True)
except subprocess.TimeoutExpired:
return "", f"Script execution timeout ({timeout}s)", 1
except FileNotFoundError:
return "", "gjs not found. Please ensure GNOME JavaScript is installed.", 1
except Exception as e:
return "", f"Script execution error: {str(e)}", 1

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

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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."""
@ -40,8 +36,7 @@ class TabManager:
saved_request_id: Optional[str] = None, saved_request_id: Optional[str] = None,
response: Optional[HttpResponse] = None, response: Optional[HttpResponse] = None,
project_id: Optional[str] = None, project_id: Optional[str] = None,
selected_environment_id: Optional[str] = None, selected_environment_id: Optional[str] = None) -> RequestTab:
scripts=None) -> RequestTab:
"""Create a new tab and add it to the collection.""" """Create a new tab and add it to the collection."""
tab_id = str(uuid.uuid4()) tab_id = str(uuid.uuid4())
@ -49,10 +44,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,
@ -70,8 +62,7 @@ class TabManager:
modified=False, modified=False,
original_request=original_request, original_request=original_request,
project_id=project_id, project_id=project_id,
selected_environment_id=selected_environment_id, selected_environment_id=selected_environment_id
scripts=scripts
) )
self.tabs.append(tab) self.tabs.append(tab)
@ -154,6 +145,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

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -24,18 +23,5 @@ from .project_item import ProjectItem
from .request_item import RequestItem from .request_item import RequestItem
from .variable_row import VariableRow from .variable_row import VariableRow
from .environment_row import EnvironmentRow from .environment_row import EnvironmentRow
from .environment_column_header import EnvironmentColumnHeader
from .environment_header_row import EnvironmentHeaderRow
from .variable_data_row import VariableDataRow
__all__ = [ __all__ = ['HeaderRow', 'HistoryItem', 'ProjectItem', 'RequestItem', 'VariableRow', 'EnvironmentRow']
'HeaderRow',
'HistoryItem',
'ProjectItem',
'RequestItem',
'VariableRow',
'EnvironmentRow',
'EnvironmentColumnHeader',
'EnvironmentHeaderRow',
'VariableDataRow',
]

View File

@ -1,52 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<template class="EnvironmentColumnHeader" parent="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">4</property>
<property name="width-request">200</property>
<child>
<object class="GtkLabel" id="name_label">
<property name="xalign">0.5</property>
<property name="ellipsize">end</property>
<property name="max-width-chars">20</property>
<style>
<class name="heading"/>
</style>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="halign">center</property>
<property name="spacing">4</property>
<child>
<object class="GtkButton" id="edit_button">
<property name="icon-name">document-properties-symbolic</property>
<property name="tooltip-text">Edit environment</property>
<signal name="clicked" handler="on_edit_clicked"/>
<style>
<class name="flat"/>
<class name="circular"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="delete_button">
<property name="icon-name">edit-delete-symbolic</property>
<property name="tooltip-text">Delete environment</property>
<signal name="clicked" handler="on_delete_clicked"/>
<style>
<class name="flat"/>
<class name="circular"/>
</style>
</object>
</child>
</object>
</child>
</template>
</interface>

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<template class="EnvironmentHeaderRow" parent="GtkBox">
<property name="orientation">horizontal</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">6</property>
<child>
<object class="GtkBox" id="variable_cell">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="width-request">150</property>
<child>
<object class="GtkLabel" id="variable_label">
<property name="label">Variable</property>
<property name="xalign">0</property>
<property name="hexpand">True</property>
<style>
<class name="heading"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="columns_box">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="hexpand">True</property>
</object>
</child>
</template>
</interface>

View File

@ -1,56 +0,0 @@
# environment_column_header.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 Gtk, GObject
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-column-header.ui')
class EnvironmentColumnHeader(Gtk.Box):
"""Widget for displaying an environment column header with edit/delete buttons."""
__gtype_name__ = 'EnvironmentColumnHeader'
name_label = Gtk.Template.Child()
edit_button = Gtk.Template.Child()
delete_button = Gtk.Template.Child()
__gsignals__ = {
'edit-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
}
def __init__(self, environment):
super().__init__()
self.environment = environment
self.name_label.set_text(environment.name)
@Gtk.Template.Callback()
def on_edit_clicked(self, button):
"""Handle edit button click."""
self.emit('edit-requested')
@Gtk.Template.Callback()
def on_delete_clicked(self, button):
"""Handle delete button click."""
self.emit('delete-requested')
def update_name(self, name):
"""Update the environment name display."""
self.name_label.set_text(name)

View File

@ -1,84 +0,0 @@
# environment_header_row.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 Gtk, GObject
from .environment_column_header import EnvironmentColumnHeader
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/environment-header-row.ui')
class EnvironmentHeaderRow(Gtk.Box):
"""Widget for the header row containing all environment column headers."""
__gtype_name__ = 'EnvironmentHeaderRow'
variable_cell = Gtk.Template.Child()
variable_label = Gtk.Template.Child()
columns_box = Gtk.Template.Child()
__gsignals__ = {
'environment-edit-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
'environment-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
}
def __init__(self, environments, size_group):
super().__init__()
self.environments = environments
self.size_group = size_group
self.column_headers = {}
# Add variable cell to size group for alignment
if size_group:
size_group.add_widget(self.variable_cell)
self._populate()
def _populate(self):
"""Populate with environment column headers."""
# Clear existing
while child := self.columns_box.get_first_child():
self.columns_box.remove(child)
self.column_headers = {}
# Create column header for each environment
for env in self.environments:
header = EnvironmentColumnHeader(env)
header.connect('edit-requested', self._on_edit_requested, env)
header.connect('delete-requested', self._on_delete_requested, env)
# Add to size group for alignment with data rows
if self.size_group:
self.size_group.add_widget(header)
self.columns_box.append(header)
self.column_headers[env.id] = header
def _on_edit_requested(self, widget, environment):
"""Handle edit request from column header."""
self.emit('environment-edit-requested', environment)
def _on_delete_requested(self, widget, environment):
"""Handle delete request from column header."""
self.emit('environment-delete-requested', environment)
def refresh(self, environments):
"""Refresh with updated environments list."""
self.environments = environments
self._populate()

View File

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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

@ -1,91 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<template class="VariableDataRow" parent="GtkBox">
<property name="orientation">horizontal</property>
<property name="spacing">12</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">6</property>
<property name="margin-bottom">6</property>
<child>
<object class="GtkBox" id="variable_cell">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="width-request">150</property>
<child>
<object class="GtkEntry" id="name_entry">
<property name="placeholder-text">Variable name</property>
<property name="hexpand">True</property>
</object>
</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>
<object class="GtkBox">
<property name="spacing">3</property>
<child>
<object class="GtkButton" id="cancel_button">
<property name="icon-name">process-stop-symbolic</property>
<property name="tooltip-text">Cancel editing</property>
<signal name="clicked" handler="on_cancel_clicked"/>
<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>
</child>
</object>
</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>
<object class="GtkBox" id="values_box">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="hexpand">True</property>
</object>
</child>
</template>
</interface>

View File

@ -1,290 +0,0 @@
# variable_data_row.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 Gtk, GObject, GLib
from datetime import datetime, timedelta
@Gtk.Template(resource_path='/cz/bugsy/roster/widgets/variable-data-row.ui')
class VariableDataRow(Gtk.Box):
"""Widget for a data row with variable name and values for each environment."""
__gtype_name__ = 'VariableDataRow'
variable_cell = 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()
values_box = Gtk.Template.Child()
__gsignals__ = {
'variable-changed': (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
'sensitivity-changed': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), # is_sensitive
}
def __init__(self, variable_name, environments, size_group, project, project_manager, update_timestamp=None):
super().__init__()
self.variable_name = variable_name
self.original_name = variable_name # Store original name for cancel
self.environments = environments
self.size_group = size_group
self.project = project
self.project_manager = project_manager
self.value_entries = {}
self.update_timeout_id = None
self._updating_toggle = False # Flag to prevent recursion
self._is_editing = False # Track editing state
# Set 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
if size_group:
size_group.add_widget(self.variable_cell)
self._populate_values()
# Apply visual marking if recently updated
if update_timestamp:
self._apply_update_marking(update_timestamp)
def _populate_values(self):
"""Populate value entries for each environment."""
# Clear existing
while child := self.values_box.get_first_child():
self.values_box.remove(child)
self.value_entries = {}
# Check if this variable is sensitive
is_sensitive = self.variable_name in self.project.sensitive_variables
# Create entry for each environment
for env in self.environments:
# Create a box to hold the entry (for size group alignment)
entry_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
entry_box.set_size_request(200, -1)
entry = Gtk.Entry()
entry.set_placeholder_text(env.name)
entry.set_hexpand(True)
# 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)
# Add entry box to size group for alignment
if self.size_group:
self.size_group.add_widget(entry_box)
self.values_box.append(entry_box)
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):
"""Handle value entry changes."""
value = 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()
def on_cancel_clicked(self, button):
"""Cancel editing and restore original name."""
self.name_entry.set_text(self.original_name)
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()
def on_delete_clicked(self, button):
"""Handle delete button click."""
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):
"""Return current variable name."""
return self.name_entry.get_text().strip()
def refresh(self, environments, project):
"""Refresh with updated environments and project."""
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()
def _apply_update_marking(self, timestamp_str):
"""
Apply visual marking to show variable was recently updated.
Args:
timestamp_str: ISO format timestamp of when variable was updated
"""
try:
# Parse timestamp
update_time = datetime.fromisoformat(timestamp_str)
time_diff = datetime.now() - update_time
# Only apply marking if updated within last 3 minutes
if time_diff < timedelta(minutes=3):
# Add CSS class for visual styling
self.add_css_class('recently-updated')
# Set tooltip with update time
seconds_ago = int(time_diff.total_seconds())
if seconds_ago < 10:
tooltip_text = "✨ Updated just now by script"
elif seconds_ago < 60:
tooltip_text = f"✨ Updated {seconds_ago} seconds ago by script"
else:
minutes_ago = seconds_ago // 60
if minutes_ago == 1:
tooltip_text = "✨ Updated 1 minute ago by script"
else:
tooltip_text = f"✨ Updated {minutes_ago} minutes ago by script"
self.set_tooltip_text(tooltip_text)
# Keep highlighting visible for 60 seconds (plenty of time to see it)
if self.update_timeout_id:
GLib.source_remove(self.update_timeout_id)
self.update_timeout_id = GLib.timeout_add_seconds(60, self._remove_update_marking)
except (ValueError, AttributeError):
# Invalid timestamp format, skip marking
pass
def _remove_update_marking(self):
"""Remove visual marking after timeout."""
self.remove_css_class('recently-updated')
self.set_tooltip_text("")
self.update_timeout_id = None
return False # Don't repeat timeout

View File

@ -6,7 +6,6 @@
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
# 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
@ -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,12 @@ 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()
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()
@ -66,6 +50,7 @@ class RosterWindow(Adw.ApplicationWindow):
# Sidebar widgets # Sidebar widgets
projects_listbox = Gtk.Template.Child() projects_listbox = Gtk.Template.Child()
add_project_button = Gtk.Template.Child() add_project_button = Gtk.Template.Child()
save_request_button = Gtk.Template.Child()
# History (hidden but kept for compatibility) # History (hidden but kept for compatibility)
history_listbox = Gtk.Template.Child() history_listbox = Gtk.Template.Child()
@ -79,13 +64,9 @@ 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
# Format: {project_id: {var_name: timestamp}}
self.recently_updated_variables: Dict[str, Dict[str, str]] = {}
# 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 +89,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 +124,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,39 +146,15 @@ 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 */
entry.warning { entry.warning {
background-color: mix(@warning_bg_color, @view_bg_color, 0.3); background-color: mix(@warning_bg_color, @view_bg_color, 0.3);
} }
/* Visual marking for recently updated variables */
.recently-updated {
background: linear-gradient(90deg,
mix(@success_bg_color, @view_bg_color, 0.25),
mix(@success_bg_color, @view_bg_color, 0.05));
border-left: 4px solid @success_color;
border-radius: 6px;
transition: all 0.3s ease;
}
.recently-updated:hover {
background: linear-gradient(90deg,
mix(@success_bg_color, @view_bg_color, 0.35),
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 +163,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 +179,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 +212,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 +238,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 +246,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 +268,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,29 +300,21 @@ 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):
"""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)
self.current_tab_id = tab.id self.current_tab_id = tab.id
# Create RequestTabWidget for this tab # Create RequestTabWidget for this tab
widget = RequestTabWidget(tab.id, request, response, project_id, selected_environment_id, scripts) widget = RequestTabWidget(tab.id, request, response, project_id, selected_environment_id)
widget.original_request = tab.original_request widget.original_request = tab.original_request
widget.project_manager = self.project_manager # Inject project manager widget.project_manager = self.project_manager # Inject project manager
# Set original scripts for change tracking (if this is a saved request)
if saved_request_id and scripts:
from .models import Scripts
widget.original_scripts = Scripts(
preprocessing=scripts.preprocessing,
postprocessing=scripts.postprocessing
)
# Populate environment dropdown if project_id is set # Populate environment dropdown if project_id is set
if project_id and hasattr(widget, '_populate_environment_dropdown'): if project_id and hasattr(widget, '_populate_environment_dropdown'):
widget._populate_environment_dropdown() widget._populate_environment_dropdown()
@ -420,7 +366,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 +374,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 +404,65 @@ 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)
widget.send_button.set_label("Sending...")
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 widget.send_button.set_label("Send")
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 else:
widget.send_button.set_sensitive(True) widget.display_error(error or "Unknown error")
# Update tab with response # Save response to tab
if response: page = self.tab_view.get_selected_page()
widget.display_response(response) if page:
tab = self.page_to_tab.get(page)
if tab:
tab.response = response
# Execute postprocessing script if exists # Create history entry (save the substituted request with actual values)
scripts = widget.get_scripts() entry = HistoryEntry(
if scripts and scripts.postprocessing.strip(): timestamp=datetime.now().isoformat(),
from .script_executor import ScriptExecutor, ScriptContext request=substituted_request,
response=response,
error=error
)
self.history_manager.add_entry(entry)
# Create context for variable updates # Refresh history panel to show new entry
context = None self._load_history()
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 self.http_client.execute_request_async(substituted_request, callback, None)
script_result = ScriptExecutor.execute_postprocessing_script(
scripts.postprocessing,
response,
context
)
# Display script results
widget.display_script_results(script_result)
# Process variable updates
self._process_variable_updates(script_result, widget, context)
else:
widget._clear_script_results()
else:
widget.display_error(error or "Unknown error")
widget._clear_script_results()
# Save response to tab
page = self.tab_view.get_selected_page()
if page:
tab = self.page_to_tab.get(page)
if tab:
tab.response = response
# Create history entry with redacted sensitive variables
# Determine which request to save to history
request_for_history = modified_request
has_redacted = False
if env and widget.project_id:
# Get the project's sensitive variables list
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)
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 +498,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,181 +540,19 @@ 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): def _show_toast(self, message):
"""
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):
"""
Process variable updates from postprocessing script.
Args:
script_result: ScriptResult containing variable updates
widget: RequestTabWidget instance
context: ScriptContext or None
"""
# Check if there are any variable updates
if not script_result.variable_updates:
return
# Show warnings if context is missing
if not context or not context.project_id or not context.environment_id:
if widget.project_id:
self._show_toast("Cannot set variables: no environment selected")
else:
self._show_toast("Cannot set variables: tab not associated with project")
return
# Get project and environment
projects = self.project_manager.load_projects()
project = None
environment = None
for p in projects:
if p.id == context.project_id:
project = p
for env in p.environments:
if env.id == context.environment_id:
environment = env
break
break
if not project or not environment:
self._show_toast("Cannot set variables: project or environment not found")
return
# Process each variable update
created_vars = []
updated_vars = []
# First pass: identify and create missing variables
for var_name in script_result.variable_updates.keys():
if var_name not in project.variable_names:
# Auto-create variable
self.project_manager.add_variable(context.project_id, var_name)
created_vars.append(var_name)
else:
updated_vars.append(var_name)
# Second pass: batch update all variable values
self.project_manager.batch_update_environment_variables(
context.project_id,
context.environment_id,
script_result.variable_updates
)
# Track updated variables for visual marking
if context.project_id not in self.recently_updated_variables:
self.recently_updated_variables[context.project_id] = {}
current_time = datetime.now().isoformat()
for var_name in script_result.variable_updates.keys():
self.recently_updated_variables[context.project_id][var_name] = current_time
# Show toast notification
env_name = environment.name
if created_vars and updated_vars:
vars_list = ', '.join(created_vars + updated_vars)
self._show_toast(f"Created {len(created_vars)} and updated {len(updated_vars)} variables in {env_name}")
elif created_vars:
vars_list = ', '.join(created_vars)
self._show_toast(f"Created {len(created_vars)} variable(s) in {env_name}: {vars_list}")
elif updated_vars:
vars_list = ', '.join(updated_vars)
self._show_toast(f"Updated {len(updated_vars)} variable(s) in {env_name}: {vars_list}")
def _show_toast(self, message: str) -> None:
"""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 +590,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 +642,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,22 +670,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) dialog = EnvironmentsDialog(project, self.project_manager)
# Note: load_projects() uses cached data that's kept up-to-date by save operations
projects = self.project_manager.load_projects()
fresh_project = None
for p in projects:
if p.id == project.id:
fresh_project = p
break
if not fresh_project:
fresh_project = project # Fallback to original if not found
# Get recently updated variables for this project
recently_updated = self.recently_updated_variables.get(project.id, {})
dialog = EnvironmentsDialog(fresh_project, self.project_manager, recently_updated)
def on_environments_updated(dlg): def on_environments_updated(dlg):
# Reload projects to reflect changes # Reload projects to reflect changes
@ -1063,7 +694,6 @@ class RosterWindow(Adw.ApplicationWindow):
return return
request = widget.get_request() request = widget.get_request()
scripts = widget.get_scripts()
if not request.url.strip(): if not request.url.strip():
self._show_toast("Cannot save: URL is empty") self._show_toast("Cannot save: URL is empty")
@ -1086,7 +716,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 +731,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,71 +761,27 @@ 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
existing = self.project_manager.find_request_by_name(project.id, name) existing = self.project_manager.find_request_by_name(project.id, name)
if existing: if existing:
# Show overwrite confirmation # Show overwrite confirmation
self._show_overwrite_dialog(project, name, existing.id, request, scripts) self._show_overwrite_dialog(project, name, existing.id, request)
else: else:
# No duplicate, save normally # No duplicate, save normally
saved_request = self.project_manager.add_request(project.id, name, request, scripts) saved_request = self.project_manager.add_request(project.id, name, request)
self._load_projects() self._load_projects()
self._show_toast(f"Saved as '{name}'") self._show_toast(f"Saved as '{name}'")
# 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)
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 _mark_tab_as_saved(self, saved_request_id, name, request):
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):
"""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()
if not page: if not page:
@ -1215,7 +795,6 @@ class RosterWindow(Adw.ApplicationWindow):
tab.saved_request_id = saved_request_id tab.saved_request_id = saved_request_id
tab.name = name tab.name = name
tab.modified = False tab.modified = False
tab.scripts = scripts
# Update original_request to match saved state # Update original_request to match saved state
original = HttpRequest( original = HttpRequest(
@ -1227,17 +806,6 @@ class RosterWindow(Adw.ApplicationWindow):
) )
tab.original_request = original tab.original_request = original
# Update original_scripts to match saved state
if scripts:
from .models import Scripts
original_scripts = Scripts(
preprocessing=scripts.preprocessing,
postprocessing=scripts.postprocessing
)
widget.original_scripts = original_scripts
else:
widget.original_scripts = None
# Update widget state # Update widget state
widget.original_request = original widget.original_request = original
widget.modified = False widget.modified = False
@ -1245,7 +813,7 @@ class RosterWindow(Adw.ApplicationWindow):
# Update tab page title (widget.modified is now False, so no star) # Update tab page title (widget.modified is now False, so no star)
page.set_title(name) page.set_title(name)
def _show_overwrite_dialog(self, project, name, existing_request_id, request, scripts=None): def _show_overwrite_dialog(self, project, name, existing_request_id, request):
"""Show dialog asking if user wants to overwrite existing request.""" """Show dialog asking if user wants to overwrite existing request."""
dialog = Adw.AlertDialog() dialog = Adw.AlertDialog()
dialog.set_heading("Request Already Exists") dialog.set_heading("Request Already Exists")
@ -1260,34 +828,60 @@ class RosterWindow(Adw.ApplicationWindow):
def on_overwrite_response(dlg, response): def on_overwrite_response(dlg, response):
if response == "overwrite": if response == "overwrite":
# Update the existing request # Update the existing request
self.project_manager.update_request(project.id, existing_request_id, name, request, scripts) self.project_manager.update_request(project.id, existing_request_id, name, request)
self._load_projects() self._load_projects()
self._show_toast(f"Updated '{name}'") self._show_toast(f"Updated '{name}'")
# Clear modified flag on current tab # Clear modified flag on current tab
self._mark_tab_as_saved(existing_request_id, name, request, scripts) self._mark_tab_as_saved(existing_request_id, name, request)
dialog.connect("response", on_overwrite_response) dialog.connect("response", on_overwrite_response)
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."""
req = saved_request.request req = saved_request.request
scripts = saved_request.scripts
# First, check if this request is already open in an unmodified tab # First, check if this request is already open in an unmodified tab
existing_tab = self._find_tab_by_saved_request_id(saved_request.id) existing_tab = self._find_tab_by_saved_request_id(saved_request.id)
@ -1327,7 +921,6 @@ class RosterWindow(Adw.ApplicationWindow):
current_tab.saved_request_id = link_to_saved current_tab.saved_request_id = link_to_saved
current_tab.project_id = project_id current_tab.project_id = project_id
current_tab.selected_environment_id = default_env_id current_tab.selected_environment_id = default_env_id
current_tab.scripts = scripts
# Update widget with project context # Update widget with project context
widget.project_id = project_id widget.project_id = project_id
@ -1341,18 +934,6 @@ class RosterWindow(Adw.ApplicationWindow):
# Load request into widget # Load request into widget
widget._load_request(req) widget._load_request(req)
# Load scripts into widget
if scripts:
widget.load_scripts(scripts)
# Set original scripts for change tracking
from .models import Scripts
widget.original_scripts = Scripts(
preprocessing=scripts.preprocessing,
postprocessing=scripts.postprocessing
)
else:
widget.original_scripts = None
if is_copy: if is_copy:
# This is a copy - mark as unsaved # This is a copy - mark as unsaved
current_tab.original_request = None current_tab.original_request = None
@ -1379,8 +960,7 @@ class RosterWindow(Adw.ApplicationWindow):
request=req, request=req,
saved_request_id=link_to_saved, saved_request_id=link_to_saved,
project_id=project_id, project_id=project_id,
selected_environment_id=default_env_id, selected_environment_id=default_env_id
scripts=scripts
) )
# If it's a copy, clear the original_request to mark as unsaved # If it's a copy, clear the original_request to mark as unsaved