Compare commits

...

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

22 changed files with 1119 additions and 965 deletions

View File

@ -12,7 +12,18 @@ A modern HTTP client for GNOME, built with GTK 4 and libadwaita.
- Environment variables with secure credential storage
- JavaScript preprocessing and postprocessing scripts
- Export requests (cURL and more)
- Beautiful GNOME-native UI
- GNOME-native UI
## Screenshots
### Main Interface
![Main Interface](screenshots/main.png)
### Request History
![Request History](screenshots/history.png)
### Environment Variables
![Environment Variables](screenshots/variables.png)
## Quick Start
@ -41,23 +52,16 @@ 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.
## Documentation
Complete documentation is available in the [Wiki](https://git.bugsy.cz/beval/roster/wiki):
- **[Home](https://git.bugsy.cz/beval/roster/wiki/Home)** - Overview and quick links
- **[Installation](https://git.bugsy.cz/beval/roster/wiki/Installation)** - Build and install instructions
- **[Getting Started](https://git.bugsy.cz/beval/roster/wiki/Getting-Started)** - Your first HTTP request
- **[Projects and Environments](https://git.bugsy.cz/beval/roster/wiki/Projects-and-Environments)** - Organize your work
- **[Variables](https://git.bugsy.cz/beval/roster/wiki/Variables)** - Use variables in requests
- **[Sensitive Variables](https://git.bugsy.cz/beval/roster/wiki/Sensitive-Variables)** - Secure credential storage with GNOME Keyring
- **[Scripts](https://git.bugsy.cz/beval/roster/wiki/Scripts)** - Automate workflows with JavaScript
- **[API Reference](https://git.bugsy.cz/beval/roster/wiki/API-Reference)** - Complete JavaScript API
- **[Keyboard Shortcuts](https://git.bugsy.cz/beval/roster/wiki/Keyboard-Shortcuts)** - Speed up your workflow
- **[FAQ](https://git.bugsy.cz/beval/roster/wiki/FAQ)** - Frequently asked questions
## 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.
@ -74,12 +78,6 @@ Use preprocessing and postprocessing scripts to:
[Learn more about Scripts](https://git.bugsy.cz/bavel/roster/wiki/Scripts)
### 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)
## Dependencies
### Runtime

View File

@ -1,230 +0,0 @@
# Sensitive Variables - UI User Guide
## Overview
Roster now supports marking variables as **sensitive** to store their values securely in GNOME Keyring instead of plain text. This guide explains how to use this feature in the UI.
## Features
### 🔒 Lock Icon Toggle
Each variable in the Environments dialog has a lock icon button:
- **Unlocked** (🔓 `channel-insecure-symbolic`) - Variable is stored in plain JSON
- **Locked** (🔒 `channel-secure-symbolic`) - Variable is stored encrypted in GNOME Keyring
### Visual Indicators
1. **Lock Icon Color/State**
- Inactive (gray): Non-sensitive variable
- Active (accent color): Sensitive variable
2. **Variable Name Styling**
- Regular variables: Normal text
- Sensitive variables: Colored with accent color and bold weight
3. **Value Entry Fields**
- Regular variables: Show plain text
- Sensitive variables: Show bullets (••••••) instead of text
## How to Use
### Step 1: Open Environments Dialog
1. Select a project from the sidebar
2. Click the "Environments" button in the header bar
3. The dialog shows a table with:
- Rows: Variable names
- Columns: Environment values
### Step 2: Create Variables
If you haven't already:
1. Click the **"Add Variable"** button (bottom-left)
2. Enter a variable name (e.g., `api_key`)
3. Click "Add"
### Step 3: Mark Variable as Sensitive
**Option A: Before Entering Values (Recommended)**
1. Find the variable row in the table
2. Click the **lock icon** next to the variable name
3. Icon changes from 🔓 to 🔒
4. Now enter your secret values - they go directly to keyring
**Option B: After Entering Values (Migration)**
1. If you already entered sensitive values in plain text
2. Click the lock icon to mark as sensitive
3. Values are automatically moved from JSON to GNOME Keyring
4. JSON file now shows empty placeholders
### Step 4: Enter Sensitive Values
1. Click in the value entry field for any environment
2. Type your secret value
3. You'll see bullets (••••••) instead of text
4. Value is automatically saved to GNOME Keyring
### Step 5: Use in Requests
Sensitive variables work exactly like regular variables:
```
URL: {{base_url}}/users
Headers:
Authorization: Bearer {{api_key}}
```
When you send the request, values are automatically retrieved from the keyring and substituted.
## UI Walkthrough
### Example: Storing a GitHub Token
**Before (Insecure - in plain JSON):**
```
Variables Table:
┌─────────────┬──────────────────┬──────────────────┐
│ Variable │ Production │ Development │
├─────────────┼──────────────────┼──────────────────┤
│ base_url 🔓 │ api.github.com │ api.dev.local │
│ token 🔓 │ ghp_abc123... │ ghp_dev456... │ ← VISIBLE!
└─────────────┴──────────────────┴──────────────────┘
```
**After (Secure - in GNOME Keyring):**
```
Variables Table:
┌─────────────┬──────────────────┬──────────────────┐
│ Variable │ Production │ Development │
├─────────────┼──────────────────┼──────────────────┤
│ base_url 🔓 │ api.github.com │ api.dev.local │
│ token 🔒 │ •••••••••••• │ •••••••••••• │ ← HIDDEN!
└─────────────┴──────────────────┴──────────────────┘
```
## Tooltips
Hover over the lock icon to see:
- **Unlocked**: "Not sensitive (stored in JSON) - Click to mark as sensitive"
- **Locked**: "Sensitive (stored in keyring) - Click to make non-sensitive"
## Making a Variable Non-Sensitive Again
If you accidentally marked a variable as sensitive:
1. Click the lock icon again (🔒 → 🔓)
2. Values are moved from keyring back to JSON
3. You can now see the values in plain text
**Warning**: Only do this if the variable truly doesn't contain secrets!
## Viewing Secrets in GNOME Keyring
Want to verify your secrets are stored?
1. Open **"Passwords and Keys"** application (Seahorse)
2. Look under **"Login"** keyring
3. Find entries labeled: `Roster: ProjectName/EnvironmentName/VariableName`
Example:
```
Login Keyring
└─ Roster: My API Project/Production/api_key
└─ Roster: My API Project/Development/api_key
```
## Best Practices
### ✅ DO Mark as Sensitive:
- `api_key` - API keys
- `secret_key` - Secret keys
- `password` - Passwords
- `token` - Bearer tokens, OAuth tokens
- `client_secret` - OAuth client secrets
- `private_key` - Private keys
- Any variable containing credentials
### ❌ DON'T Mark as Sensitive:
- `base_url` - API endpoints
- `env` - Environment names
- `region` - Cloud regions
- `timeout` - Timeout values
- `version` - API versions
- Any non-secret configuration
### Workflow Tips
1. **Create variables first, then mark as sensitive**
- Add variable
- Click lock icon
- Enter values (they go directly to keyring)
2. **Use descriptive names**
- ✅ `stripe_secret_key`
- ❌ `key1`
3. **Group related variables**
```
base_url 🔓
api_key 🔒
api_secret 🔒
timeout 🔓
```
4. **Audit regularly**
- Check which variables are marked as sensitive
- Ensure all secrets are locked (🔒)
## Troubleshooting
### Q: I clicked the lock but it didn't work
**A:** Check the application logs. The keyring might be locked. Unlock it with your login password.
### Q: Can I see my secret values after marking as sensitive?
**A:** The UI shows bullets (••••••) for security. To view:
- Temporarily unlock (click 🔒 → 🔓)
- Or use "Passwords and Keys" app to view in keyring
### Q: What happens if I delete a sensitive variable?
**A:** Both the JSON entry AND the keyring secret are deleted automatically.
### Q: What happens if I rename a sensitive variable?
**A:** The keyring secret is automatically renamed. No data is lost.
### Q: What happens if I delete an environment?
**A:** All keyring secrets for that environment are deleted automatically.
### Q: What happens if I delete a project?
**A:** ALL keyring secrets for that project are deleted automatically.
## Security Notes
- ✅ Secrets are encrypted with your login password
- ✅ Automatically unlocked when you log in
- ✅ Protected by OS-level security
- ✅ Same security as browser passwords, WiFi passwords, SSH keys
- ⚠️ Non-sensitive variables are still in plain JSON
- ⚠️ Mark variables as sensitive BEFORE entering secret values
## Keyboard Shortcuts
- **Tab**: Move between value fields
- **Enter**: Confirm value (auto-saved)
- **Escape**: Close dialog
## Visual Design
The UI uses the following visual cues:
1. **Lock Icon State**
- Gray/inactive = Plain JSON storage
- Colored/active = Encrypted keyring storage
2. **Variable Name Color**
- Sensitive variables have accent color and bold font
3. **Password Entry**
- Sensitive variable values show as bullets
- Same UX as password fields throughout GNOME
4. **Consistent with GNOME HIG**
- Follows GNOME Human Interface Guidelines
- Familiar patterns (lock icon = security)
- Accessible and keyboard-navigable

View File

@ -1,271 +0,0 @@
# Sensitive Variables - Usage Guide
This document explains how to use the GNOME Keyring integration for storing sensitive variable values securely.
## Overview
Roster now supports marking variables as **sensitive**, which stores their values encrypted in GNOME Keyring instead of plain text in the JSON file.
**Use sensitive variables for:**
- API keys (`api_key`, `secret_key`)
- Authentication tokens (`bearer_token`, `oauth_token`)
- Passwords (`password`, `db_password`)
- Any secret credentials
**Use regular variables for:**
- Base URLs (`base_url`, `api_endpoint`)
- Environment names (`env`, `region`)
- Non-sensitive configuration values
## Storage
### Regular Variables
Stored in: `~/.local/share/cz.bugsy.roster/requests.json`
```json
{
"projects": [{
"variable_names": ["base_url", "api_key"],
"sensitive_variables": [], // empty = not sensitive
"environments": [{
"variables": {
"base_url": "https://api.example.com", // visible in JSON
"api_key": "secret-key-123" // visible (NOT SAFE!)
}
}]
}]
}
```
### Sensitive Variables
Stored in: GNOME Keyring (encrypted)
```json
{
"projects": [{
"variable_names": ["base_url", "api_key"],
"sensitive_variables": ["api_key"], // marked as sensitive
"environments": [{
"variables": {
"base_url": "https://api.example.com", // visible in JSON
"api_key": "" // empty placeholder
}
}]
}]
}
```
The actual value of `api_key` is stored encrypted in GNOME Keyring and automatically retrieved when needed.
## Code Examples
### Basic Usage
```python
from roster.project_manager import ProjectManager
pm = ProjectManager()
project_id = "your-project-id"
env_id = "your-environment-id"
# Mark a variable as sensitive (moves existing values to keyring)
pm.mark_variable_as_sensitive(project_id, "api_key")
# Set a sensitive variable value (goes to keyring)
pm.set_variable_value(project_id, env_id, "api_key", "my-secret-key-123")
# Get a sensitive variable value (retrieved from keyring)
value = pm.get_variable_value(project_id, env_id, "api_key")
# Returns: "my-secret-key-123"
# Check if variable is sensitive
is_sensitive = pm.is_variable_sensitive(project_id, "api_key")
# Returns: True
```
### Using in Request Substitution
```python
# Get environment with ALL values (including secrets from keyring)
environment = pm.get_environment_with_secrets(project_id, env_id)
# Now use this environment for variable substitution
from roster.variable_substitution import VariableSubstitution
vs = VariableSubstitution()
request = HttpRequest(
method="GET",
url="{{base_url}}/users",
headers={"Authorization": "Bearer {{api_key}}"},
body=""
)
substituted_request, undefined = vs.substitute_request(request, environment)
# Result:
# url = "https://api.example.com/users"
# headers = {"Authorization": "Bearer my-secret-key-123"}
```
### Mark Variable as Sensitive
```python
# Existing variable with values in all environments
pm.mark_variable_as_sensitive(project_id, "token")
# This will:
# 1. Add "token" to project.sensitive_variables list
# 2. Move all environment values to GNOME Keyring
# 3. Clear values in JSON (replaced with empty strings)
```
### Mark Variable as Non-Sensitive
```python
# Move back from keyring to JSON
pm.mark_variable_as_nonsensitive(project_id, "base_url")
# This will:
# 1. Remove "base_url" from project.sensitive_variables list
# 2. Move all environment values from keyring to JSON
# 3. Delete secrets from keyring
```
### Variable Operations (Automatic Secret Handling)
```python
# Rename a sensitive variable
pm.rename_variable(project_id, "old_token", "new_token")
# Automatically renames in both JSON AND keyring
# Delete a sensitive variable
pm.delete_variable(project_id, "api_key")
# Automatically deletes from both JSON AND keyring
# Delete an environment
pm.delete_environment(project_id, env_id)
# Automatically deletes all secrets for that environment
# Delete a project
pm.delete_project(project_id)
# Automatically deletes ALL secrets for that project
```
## Integration with UI
When implementing UI for sensitive variables:
### Variable List Display
```python
# Show lock icon for sensitive variables
for var_name in project.variable_names:
is_sensitive = var_name in project.sensitive_variables
icon = "🔒" if is_sensitive else ""
label = f"{icon} {var_name}"
```
### Variable Value Entry
```python
# Use password entry for sensitive variables
entry = Gtk.Entry()
if var_name in project.sensitive_variables:
entry.set_visibility(False) # Show bullets instead of text
entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
```
### Context Menu
```python
# Right-click menu on variable
menu = Gio.Menu()
if pm.is_variable_sensitive(project_id, var_name):
menu.append("Mark as Non-Sensitive", f"app.mark-nonsensitive::{var_name}")
else:
menu.append("Mark as Sensitive", f"app.mark-sensitive::{var_name}")
```
## How It Works
### GNOME Keyring Schema
Each secret is stored with these attributes:
```python
{
"project_id": "uuid-of-project",
"environment_id": "uuid-of-environment",
"variable_name": "api_key"
}
```
Label shown in Seahorse (Passwords and Keys app):
```
"Roster: ProjectName/EnvironmentName/variable_name"
```
### Viewing Secrets in Seahorse
1. Open "Passwords and Keys" application
2. Look under "Login" keyring
3. Find entries labeled "Roster: ..."
4. These are your sensitive variable values
### Security
- Encrypted at rest with your login password
- Automatically unlocked when you log in
- Protected by OS-level security
- Uses the same secure backend as browser passwords, WiFi passwords, SSH keys
## Migration Guide
If you have existing variables with sensitive data:
```python
project_id = "your-project-id"
sensitive_vars = ["api_key", "token", "password", "secret"]
for var_name in sensitive_vars:
pm.mark_variable_as_sensitive(project_id, var_name)
# Done! All values are now encrypted in GNOME Keyring
```
## Error Handling
```python
from roster.secret_manager import get_secret_manager
sm = get_secret_manager()
# store_secret returns bool
success = sm.store_secret(project_id, env_id, "api_key", "value")
if not success:
# Handle error (check logs)
print("Failed to store secret")
# retrieve_secret returns None on failure
value = sm.retrieve_secret(project_id, env_id, "api_key")
if value is None:
# Secret not found or error occurred
print("Secret not found")
```
## Best Practices
1. **Mark variables as sensitive BEFORE entering values**
- This ensures values never touch the JSON file
2. **Use descriptive variable names**
- `github_token` instead of `token`
- `stripe_api_key` instead of `key`
3. **Don't commit the JSON file with sensitive data**
- If you accidentally stored secrets in JSON, mark as sensitive to move them
4. **Regular variables are fine for most things**
- Only use sensitive variables for actual secrets
- Base URLs, regions, etc. don't need encryption
5. **Backup your GNOME Keyring**
- Sensitive variables are stored in your system keyring
- Backup `~/.local/share/keyrings/` if needed

View File

@ -0,0 +1,39 @@
{
"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

@ -9,8 +9,7 @@
"--share=ipc",
"--socket=fallback-x11",
"--device=dri",
"--socket=wayland",
"--talk-name=org.freedesktop.secrets"
"--socket=wayland"
],
"cleanup" : [
"/include",

View File

@ -8,7 +8,6 @@
<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>
<p>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.</p>
</description>
<developer id="cz.bugsy">
@ -26,18 +25,71 @@
<!-- 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://example.org/example1.png</image>
<caption>A caption</caption>
<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://example.org/example2.png</image>
<caption>A caption</caption>
<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>

View File

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

View File

@ -3,6 +3,19 @@
data/cz.bugsy.roster.desktop.in
data/cz.bugsy.roster.metainfo.xml.in
data/cz.bugsy.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-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.ui

182
po/roster.pot Normal file
View File

@ -0,0 +1,182 @@
# 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 ""

BIN
screenshots/history.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
screenshots/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
screenshots/variables.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@ -5,7 +5,7 @@
<template class="EnvironmentsDialog" parent="AdwDialog">
<property name="title">Manage Environments</property>
<property name="content-width">900</property>
<property name="content-width">1100</property>
<property name="content-height">600</property>
<property name="follows-content-size">false</property>

View File

@ -169,10 +169,12 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response):
if response == "delete":
self.project_manager.delete_variable(self.project.id, var_name)
self._reload_project()
self._populate_table()
self.emit('environments-updated')
def on_delete_complete():
self._reload_project()
self._populate_table()
self.emit('environments-updated')
self.project_manager.delete_variable(self.project.id, var_name, on_delete_complete)
dialog.connect("response", on_response)
dialog.present(self)
@ -181,10 +183,12 @@ class EnvironmentsDialog(Adw.Dialog):
"""Handle variable name change."""
new_name = row.get_variable_name()
if new_name and new_name != old_name and new_name not in self.project.variable_names:
self.project_manager.rename_variable(self.project.id, old_name, new_name)
self._reload_project()
self._populate_table()
self.emit('environments-updated')
def on_rename_complete():
self._reload_project()
self._populate_table()
self.emit('environments-updated')
self.project_manager.rename_variable(self.project.id, old_name, new_name, on_rename_complete)
@Gtk.Template.Callback()
def on_add_environment_clicked(self, button):
@ -267,10 +271,12 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response):
if response == "delete":
self.project_manager.delete_environment(self.project.id, environment.id)
self._reload_project()
self._populate_table()
self.emit('environments-updated')
def on_delete_complete():
self._reload_project()
self._populate_table()
self.emit('environments-updated')
self.project_manager.delete_environment(self.project.id, environment.id, on_delete_complete)
dialog.connect("response", on_response)
dialog.present(self)
@ -283,22 +289,19 @@ class EnvironmentsDialog(Adw.Dialog):
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)
success = self.project_manager.mark_variable_as_sensitive(self.project.id, var_name)
if success:
# Reload and refresh UI
self._reload_project()
self._populate_table()
self.emit('environments-updated')
self.project_manager.mark_variable_as_sensitive(self.project.id, var_name, on_complete)
else:
# Mark as non-sensitive (move to JSON)
success = self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name)
if success:
# Reload and refresh UI
self._reload_project()
self._populate_table()
self.emit('environments-updated')
self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name, on_complete)
def _reload_project(self):
"""Reload project data from manager."""

View File

@ -35,8 +35,11 @@ class HttpRequest:
@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/{constants.VERSION}"
"User-Agent": f"Roster/{short_version}"
}
def to_dict(self):

View File

@ -22,7 +22,7 @@ import json
import uuid
import logging
from pathlib import Path
from typing import List, Optional
from typing import List, Optional, Callable
from datetime import datetime, timezone
import gi
gi.require_version('GLib', '2.0')
@ -132,18 +132,23 @@ class ProjectManager:
break
self.save_projects(projects)
def delete_project(self, project_id: str):
"""Delete a project and all its requests."""
def delete_project(self, project_id: str,
callback: Optional[Callable[[], None]] = None):
"""Delete a project and all its requests (async for secret cleanup)."""
projects = self.load_projects()
secret_manager = get_secret_manager()
# Delete all secrets for this project
secret_manager.delete_all_project_secrets(project_id)
# Remove project from list
# Remove project from list and save immediately
projects = [p for p in projects if p.id != project_id]
self.save_projects(projects)
# Delete all secrets for this project asynchronously
def on_secrets_deleted(success):
if callback:
callback()
secret_manager.delete_all_project_secrets(project_id, on_secrets_deleted)
def add_request(self, project_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest:
"""Add request to a project."""
projects = self.load_projects()
@ -239,20 +244,27 @@ class ProjectManager:
break
self.save_projects(projects)
def delete_environment(self, project_id: str, env_id: str):
"""Delete an environment."""
def delete_environment(self, project_id: str, env_id: str,
callback: Optional[Callable[[], None]] = None):
"""Delete an environment (async for secret cleanup)."""
projects = self.load_projects()
secret_manager = get_secret_manager()
for p in projects:
if p.id == project_id:
# Delete all secrets for this environment
secret_manager.delete_all_environment_secrets(project_id, env_id)
# Remove environment from list
p.environments = [e for e in p.environments if e.id != env_id]
break
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):
"""Add a variable to the project and all environments."""
projects = self.load_projects()
@ -266,10 +278,12 @@ class ProjectManager:
break
self.save_projects(projects)
def rename_variable(self, project_id: str, old_name: str, new_name: str):
"""Rename a variable in the project and all environments."""
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 (async for secrets)."""
projects = self.load_projects()
secret_manager = get_secret_manager()
is_sensitive = False
for p in projects:
if p.id == project_id:
@ -278,24 +292,33 @@ class ProjectManager:
idx = p.variable_names.index(old_name)
p.variable_names[idx] = new_name
# Update sensitive_variables list if applicable
# 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
# Rename secrets in keyring
secret_manager.rename_variable_secrets(project_id, old_name, new_name)
# Update all environments
for env in p.environments:
if old_name in env.variables:
env.variables[new_name] = env.variables.pop(old_name)
break
self.save_projects(projects)
def delete_variable(self, project_id: str, variable_name: str):
"""Delete a variable from the project and all environments."""
# Rename secrets in keyring if sensitive
if is_sensitive:
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()
secret_manager = get_secret_manager()
is_sensitive = False
environments_to_cleanup = []
for p in projects:
if p.id == project_id:
@ -303,19 +326,33 @@ class ProjectManager:
if variable_name in p.variable_names:
p.variable_names.remove(variable_name)
# Remove from sensitive_variables if applicable
# Check if sensitive and get environments for cleanup
if variable_name in p.sensitive_variables:
is_sensitive = True
p.sensitive_variables.remove(variable_name)
# Delete all secrets for this variable
for env in p.environments:
secret_manager.delete_secret(project_id, env.id, variable_name)
environments_to_cleanup = [env.id for env in p.environments]
# Remove from all environments
for env in p.environments:
env.variables.pop(variable_name, None)
break
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):
"""Update a single variable value in an environment."""
projects = self.load_projects()
@ -351,9 +388,10 @@ class ProjectManager:
# ========== Sensitive Variable Management ==========
def mark_variable_as_sensitive(self, project_id: str, variable_name: str) -> bool:
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).
Mark a variable as sensitive (move values from JSON to keyring) - async.
This will:
1. Add variable to sensitive_variables list
@ -363,12 +401,11 @@ class ProjectManager:
Args:
project_id: Project ID
variable_name: Variable name to mark as sensitive
Returns:
True if successful
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:
@ -376,30 +413,56 @@ class ProjectManager:
if variable_name not in p.sensitive_variables:
p.sensitive_variables.append(variable_name)
# Move all environment values to keyring
# Collect all environment values to store in keyring
for env in p.environments:
if variable_name in env.variables:
value = env.variables[variable_name]
# Store in keyring
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
)
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)
return True
return False
# Store all secrets asynchronously
if not secrets_to_store:
if callback:
callback(True)
return
def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str) -> bool:
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).
Mark a variable as non-sensitive (move values from keyring to JSON) - async.
This will:
1. Remove variable from sensitive_variables list
@ -409,40 +472,62 @@ class ProjectManager:
Args:
project_id: Project ID
variable_name: Variable name to mark as non-sensitive
Returns:
True if successful
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)
# Move all environment values from keyring to JSON
for env in p.environments:
# Retrieve from keyring
value = secret_manager.retrieve_secret(
project_id=project_id,
environment_id=env.id,
variable_name=variable_name
)
# Store in JSON
env.variables[variable_name] = value or ""
# Delete from keyring
secret_manager.delete_secret(
project_id=project_id,
environment_id=env.id,
variable_name=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)
return True
return False
# 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."""
@ -452,9 +537,10 @@ class ProjectManager:
return variable_name in p.sensitive_variables
return False
def get_variable_value(self, project_id: str, env_id: str, variable_name: str) -> str:
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).
Get variable value from appropriate storage (keyring or JSON) - async.
This is a convenience method that handles both sensitive and non-sensitive variables.
@ -462,9 +548,7 @@ class ProjectManager:
project_id: Project ID
env_id: Environment ID
variable_name: Variable name
Returns:
Variable value (empty string if not found)
callback: Callback called with variable value (empty string if not found)
"""
projects = self.load_projects()
@ -472,25 +556,32 @@ class ProjectManager:
if p.id == project_id:
# Check if sensitive
if variable_name in p.sensitive_variables:
# Retrieve from keyring
# Retrieve from keyring asynchronously
secret_manager = get_secret_manager()
value = secret_manager.retrieve_secret(
def on_retrieve(value):
callback(value or "")
secret_manager.retrieve_secret(
project_id=project_id,
environment_id=env_id,
variable_name=variable_name
variable_name=variable_name,
callback=on_retrieve
)
return value or ""
return
else:
# Retrieve from JSON
# Retrieve from JSON synchronously
for env in p.environments:
if env.id == env_id:
return env.variables.get(variable_name, "")
callback(env.variables.get(variable_name, ""))
return
return ""
callback("")
def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str):
def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str,
callback: Optional[Callable[[bool], None]] = None):
"""
Set variable value in appropriate storage (keyring or JSON).
Set variable value in appropriate storage (keyring or JSON) - async.
This is a convenience method that handles both sensitive and non-sensitive variables.
@ -499,6 +590,7 @@ class ProjectManager:
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()
@ -507,7 +599,7 @@ class ProjectManager:
if p.id == project_id:
# Check if sensitive
if variable_name in p.sensitive_variables:
# Store in keyring
# Store in keyring asynchronously
for env in p.environments:
if env.id == env_id:
secret_manager.store_secret(
@ -516,24 +608,32 @@ class ProjectManager:
variable_name=variable_name,
value=value,
project_name=p.name,
environment_name=env.name
environment_name=env.name,
callback=callback
)
# Keep empty placeholder in JSON
env.variables[variable_name] = ""
break
self.save_projects(projects)
return
else:
# Store in JSON
# Store in JSON synchronously
for env in p.environments:
if env.id == env_id:
env.variables[variable_name] = value
break
self.save_projects(projects)
return
self.save_projects(projects)
if callback:
callback(True)
return
def get_environment_with_secrets(self, project_id: str, env_id: str) -> Optional[Environment]:
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).
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.
@ -541,9 +641,7 @@ class ProjectManager:
Args:
project_id: Project ID
env_id: Environment ID
Returns:
Environment object with all values, or None if not found
callback: Callback called with Environment object (or None if not found)
"""
projects = self.load_projects()
secret_manager = get_secret_manager()
@ -560,16 +658,24 @@ class ProjectManager:
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
for var_name in p.sensitive_variables:
value = secret_manager.retrieve_secret(
project_id=project_id,
environment_id=env_id,
variable_name=var_name
)
if value is not None:
complete_env.variables[var_name] = value
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)
return 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
return None
callback(None)

View File

@ -1318,45 +1318,55 @@ class RequestTabWidget(Gtk.Box):
# Always update visual indicators when environment changes
self._update_variable_indicators()
def get_selected_environment(self):
def get_selected_environment(self, callback):
"""
Get the currently selected environment object with all values.
Get the currently selected environment object with all values (async).
This includes sensitive variable values from the keyring.
Args:
callback: Function called with the Environment object (or None)
"""
if not self.selected_environment_id or not self.project_manager or not self.project_id:
return None
callback(None)
return
# Get environment with secrets (includes values from keyring)
return self.project_manager.get_environment_with_secrets(
# Get environment with secrets (includes values from keyring) - async
self.project_manager.get_environment_with_secrets(
self.project_id,
self.selected_environment_id
self.selected_environment_id,
callback
)
def _detect_undefined_variables(self):
"""Detect undefined variables in the current request. Returns set of undefined variable names."""
def _detect_undefined_variables(self, callback):
"""Detect undefined variables in the current request (async).
Args:
callback: Function called with set of undefined variable names
"""
from .variable_substitution import VariableSubstitution
# Get current request from UI
request = self.get_request()
# Get selected environment
env = self.get_selected_environment()
def on_environment_loaded(env):
if not env:
# No environment selected - ALL variables are undefined
# Find all variables in the request
all_vars = set()
all_vars.update(VariableSubstitution.find_variables(request.url))
all_vars.update(VariableSubstitution.find_variables(request.body))
for key, value in request.headers.items():
all_vars.update(VariableSubstitution.find_variables(key))
all_vars.update(VariableSubstitution.find_variables(value))
callback(all_vars)
else:
# Environment selected - find which variables are undefined
_, undefined = VariableSubstitution.substitute_request(request, env)
callback(undefined)
if not env:
# No environment selected - ALL variables are undefined
# Find all variables in the request
all_vars = set()
all_vars.update(VariableSubstitution.find_variables(request.url))
all_vars.update(VariableSubstitution.find_variables(request.body))
for key, value in request.headers.items():
all_vars.update(VariableSubstitution.find_variables(key))
all_vars.update(VariableSubstitution.find_variables(value))
return all_vars
else:
# Environment selected - find which variables are undefined
_, undefined = VariableSubstitution.substitute_request(request, env)
return undefined
# Get selected environment asynchronously
self.get_selected_environment(on_environment_loaded)
def _schedule_indicator_update(self):
"""Schedule an indicator update with debouncing."""
@ -1374,22 +1384,26 @@ class RequestTabWidget(Gtk.Box):
return False # Don't repeat
def _update_variable_indicators(self):
"""Update visual indicators for undefined variables."""
"""Update visual indicators for undefined variables (async)."""
# Only update if we have a project (variables only make sense in project context)
if not self.project_id:
return
# Detect undefined variables
self.undefined_variables = self._detect_undefined_variables()
def on_undefined_detected(undefined_vars):
# Store detected undefined variables
self.undefined_variables = undefined_vars
# Update URL entry
self._update_url_indicator()
# Update URL entry
self._update_url_indicator()
# Update headers
self._update_header_indicators()
# Update headers
self._update_header_indicators()
# Update body
self._update_body_indicators()
# Update body
self._update_body_indicators()
# Detect undefined variables asynchronously
self._detect_undefined_variables(on_undefined_detected)
def _update_url_indicator(self):
"""Update warning indicator on URL entry."""

View File

@ -23,6 +23,9 @@ 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
@ -31,8 +34,9 @@ Each secret is identified by:
import gi
gi.require_version('Secret', '1')
from gi.repository import Secret, GLib
from gi.repository import Secret, GLib, Gio
import logging
from typing import Callable, Optional, Any
logger = logging.getLogger(__name__)
@ -50,7 +54,10 @@ ROSTER_SCHEMA = Secret.Schema.new(
class SecretManager:
"""Manages sensitive variable storage using GNOME Keyring via libsecret."""
"""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."""
@ -58,9 +65,10 @@ class SecretManager:
def store_secret(self, project_id: str, environment_id: str,
variable_name: str, value: str, project_name: str = "",
environment_name: str = "") -> bool:
environment_name: str = "",
callback: Optional[Callable[[bool], None]] = None):
"""
Store a sensitive variable value in GNOME Keyring.
Store a sensitive variable value in GNOME Keyring (async).
Args:
project_id: UUID of the project
@ -69,9 +77,7 @@ class SecretManager:
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)
Returns:
True if successful, False otherwise
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 \
@ -84,36 +90,40 @@ class SecretManager:
"variable_name": variable_name,
}
try:
# Store the secret synchronously
# Using PASSWORD collection (default keyring)
Secret.password_store_sync(
self.schema,
attributes,
Secret.COLLECTION_DEFAULT,
label,
value,
None # cancellable
)
logger.info(f"Stored secret for {variable_name} in {environment_id}")
return True
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)
except GLib.Error as e:
logger.error(f"Failed to store secret: {e.message}")
return 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) -> str | None:
variable_name: str,
callback: Callable[[Optional[str]], None]):
"""
Retrieve a sensitive variable value from GNOME Keyring.
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
Returns:
The secret value if found, None otherwise
callback: Callback function called with the secret value (or None if not found)
"""
attributes = {
"project_id": project_id,
@ -121,37 +131,38 @@ class SecretManager:
"variable_name": variable_name,
}
try:
# Retrieve the secret synchronously
value = Secret.password_lookup_sync(
self.schema,
attributes,
None # cancellable
)
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)
if value is None:
logger.debug(f"No secret found for {variable_name}")
else:
logger.debug(f"Retrieved secret for {variable_name}")
return value
except GLib.Error as e:
logger.error(f"Failed to retrieve secret: {e.message}")
return 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) -> bool:
variable_name: str,
callback: Optional[Callable[[bool], None]] = None):
"""
Delete a sensitive variable value from GNOME Keyring.
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
Returns:
True if deleted (or didn't exist), False on error
callback: Optional callback function called with success boolean
"""
attributes = {
"project_id": project_id,
@ -159,85 +170,98 @@ class SecretManager:
"variable_name": variable_name,
}
try:
# Delete the secret synchronously
deleted = Secret.password_clear_sync(
self.schema,
attributes,
None # cancellable
)
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)
if deleted:
logger.info(f"Deleted secret for {variable_name}")
else:
logger.debug(f"No secret to delete for {variable_name}")
# Delete the secret asynchronously
Secret.password_clear(
self.schema,
attributes,
None, # cancellable
on_clear_complete,
None # user_data
)
return True
except GLib.Error as e:
logger.error(f"Failed to delete secret: {e.message}")
return False
def delete_all_project_secrets(self, project_id: str) -> bool:
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
Returns:
True if successful, False otherwise
callback: Optional callback function called with success boolean
"""
attributes = {
"project_id": project_id,
}
try:
# Delete all matching secrets
deleted = Secret.password_clear_sync(
self.schema,
attributes,
None
)
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)
logger.info(f"Deleted all secrets for project {project_id}")
return True
# Delete all matching secrets asynchronously
Secret.password_clear(
self.schema,
attributes,
None,
on_clear_complete,
None
)
except GLib.Error as e:
logger.error(f"Failed to delete project secrets: {e.message}")
return False
def delete_all_environment_secrets(self, project_id: str, environment_id: str) -> bool:
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
Returns:
True if successful, False otherwise
callback: Optional callback function called with success boolean
"""
attributes = {
"project_id": project_id,
"environment_id": environment_id,
}
try:
deleted = Secret.password_clear_sync(
self.schema,
attributes,
None
)
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)
logger.info(f"Deleted all secrets for environment {environment_id}")
return True
Secret.password_clear(
self.schema,
attributes,
None,
on_clear_complete,
None
)
except GLib.Error as e:
logger.error(f"Failed to delete environment secrets: {e.message}")
return False
def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str) -> bool:
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).
@ -250,65 +274,174 @@ class SecretManager:
project_id: UUID of the project
old_name: Current variable name
new_name: New variable name
Returns:
True if successful, False otherwise
callback: Optional callback function called with success boolean
"""
# Search for all secrets with the old variable name
attributes = {
"project_id": project_id,
"variable_name": old_name,
}
try:
# Search for matching secrets
# This returns a list of Secret.Item objects
secrets = Secret.password_search_sync(
self.schema,
attributes,
Secret.SearchFlags.ALL,
None
)
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}")
return True
if not secrets:
logger.debug(f"No secrets found for variable {old_name}")
if callback:
callback(True)
return
# For each secret, retrieve value, store with new name, delete old
for item in secrets:
# Get the secret value
item.load_secret_sync(None)
value = item.get_secret().get_text()
# Track how many secrets we need to process
pending_count = [len(secrets)]
all_success = [True]
# Get environment_id from attributes
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(f"Secret missing environment_id, skipping")
continue
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)
# Store with new name
self.store_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=new_name,
value=value
value=value,
callback=on_store_done
)
# Delete old secret
self.delete_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=old_name
)
except GLib.Error as e:
logger.error(f"Failed to load secret for rename: {e.message}")
callback(False)
logger.info(f"Renamed variable secrets from {old_name} to {new_name}")
return True
item.load_secret(None, on_load_complete, None)
except GLib.Error as e:
logger.error(f"Failed to rename variable secrets: {e.message}")
return False
# ========== 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

View File

@ -26,6 +26,18 @@
<property name="accelerator">&lt;Control&gt;s</property>
</object>
</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>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Show Shortcuts</property>

View File

@ -19,31 +19,64 @@
<object class="GtkEntry" id="name_entry">
<property name="placeholder-text">Variable name</property>
<property name="hexpand">True</property>
<signal name="changed" handler="on_name_changed"/>
</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="GtkToggleButton" id="sensitive_toggle">
<property name="icon-name">channel-insecure-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 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="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="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>

View File

@ -30,6 +30,9 @@ class VariableDataRow(Gtk.Box):
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()
@ -44,6 +47,7 @@ class VariableDataRow(Gtk.Box):
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
@ -51,10 +55,20 @@ class VariableDataRow(Gtk.Box):
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
@ -93,22 +107,12 @@ class VariableDataRow(Gtk.Box):
entry.set_placeholder_text(env.name)
entry.set_hexpand(True)
# Get value from appropriate storage (keyring or JSON)
value = self.project_manager.get_variable_value(
self.project.id,
env.id,
self.variable_name
)
entry.set_text(value)
# 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.connect('changed', self._on_value_changed, env.id)
entry_box.append(entry)
# Add entry box to size group for alignment
@ -118,22 +122,74 @@ class VariableDataRow(Gtk.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()
# Use project_manager to store value in appropriate location
# 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
value,
callback=None # Fire and forget - errors are logged
)
self.emit('value-changed', env_id, value)
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_name_changed(self, entry):
"""Handle variable name changes."""
self.emit('variable-changed')
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):
@ -159,7 +215,7 @@ class VariableDataRow(Gtk.Box):
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("channel-insecure-symbolic")
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")
@ -172,6 +228,9 @@ class VariableDataRow(Gtk.Box):
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

View File

@ -434,6 +434,23 @@ class RosterWindow(Adw.ApplicationWindow):
if 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):
"""Handle New Request button click."""
self._create_new_tab()
@ -458,6 +475,18 @@ class RosterWindow(Adw.ApplicationWindow):
self.add_action(action)
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):
"""Handle Send button click from a tab widget."""
# Clear previous preprocessing results
@ -506,10 +535,13 @@ class RosterWindow(Adw.ApplicationWindow):
if preprocessing_result.modified_request:
modified_request = preprocessing_result.modified_request
# Apply variable substitution to (possibly modified) request
substituted_request = modified_request
if widget.selected_environment_id:
env = widget.get_selected_environment()
# Disable send button during request
widget.send_button.set_sensitive(False)
def proceed_with_request(env):
"""Continue with request after environment is loaded."""
# Apply variable substitution to (possibly modified) request
substituted_request = modified_request
if env:
from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(modified_request, env)
@ -517,65 +549,60 @@ class RosterWindow(Adw.ApplicationWindow):
if undefined:
logger.warning(f"Undefined variables in request: {', '.join(undefined)}")
# Disable send button during request
widget.send_button.set_sensitive(False)
# Execute async
def callback(response, error, user_data):
"""Callback runs on main thread."""
# Re-enable send button
widget.send_button.set_sensitive(True)
# Execute async
def callback(response, error, user_data):
"""Callback runs on main thread."""
# Re-enable send button
widget.send_button.set_sensitive(True)
# Update tab with response
if response:
widget.display_response(response)
# Update tab with response
if response:
widget.display_response(response)
# Execute postprocessing script if exists
scripts = widget.get_scripts()
if scripts and scripts.postprocessing.strip():
from .script_executor import ScriptExecutor, ScriptContext
# Execute postprocessing script if exists
scripts = widget.get_scripts()
if scripts and scripts.postprocessing.strip():
from .script_executor import ScriptExecutor, ScriptContext
# 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
)
# 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
# Execute script with context
script_result = ScriptExecutor.execute_postprocessing_script(
scripts.postprocessing,
response,
context
)
# Execute script with context
script_result = ScriptExecutor.execute_postprocessing_script(
scripts.postprocessing,
response,
context
)
# Display script results
widget.display_script_results(script_result)
# Display script results
widget.display_script_results(script_result)
# Process variable updates
self._process_variable_updates(script_result, widget, context)
# 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()
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
# 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
# Create history entry with redacted sensitive variables
# Determine which request to save to history
request_for_history = modified_request
has_redacted = False
if widget.selected_environment_id:
env = widget.get_selected_environment()
if env and widget.project_id:
# Get the project's sensitive variables list
projects = self.project_manager.load_projects()
@ -595,22 +622,28 @@ class RosterWindow(Adw.ApplicationWindow):
# No sensitive variables defined, use fully substituted request
request_for_history = substituted_request
elif env:
# Environment selected but no project - use fully substituted request
# 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)
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()
# Refresh history panel to show new entry
self._load_history()
self.http_client.execute_request_async(substituted_request, callback, None)
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
def _load_history(self) -> None:
@ -1053,7 +1086,7 @@ class RosterWindow(Adw.ApplicationWindow):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
# Check if current tab has a saved request (for pre-filling)
# Check if current tab has a saved request or project (for pre-filling)
current_tab = None
preselect_project_index = 0
prefill_name = ""
@ -1068,7 +1101,13 @@ class RosterWindow(Adw.ApplicationWindow):
preselect_project_index = i
prefill_name = current_tab.name
break
elif current_tab.name and current_tab.name != "New Request":
elif current_tab.project_id:
# 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
prefill_name = current_tab.name
@ -1232,48 +1271,18 @@ class RosterWindow(Adw.ApplicationWindow):
dialog.present(self)
def _on_add_to_project(self, widget, project):
"""Save current request to specific project."""
request = self._build_request_from_ui()
"""Create a new empty request tab associated with the project."""
# Get default environment for the project
default_env_id = project.environments[0].id if project.environments else None
if not request.url.strip():
self._show_toast("Cannot save: URL is empty")
return
# Create a new tab with project_id set so Save will pre-select this project
self._create_new_tab(
name="New Request",
project_id=project.id,
selected_environment_id=default_env_id
)
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()
is_valid, error_msg = self._validate_request_name(name)
if is_valid:
# 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)
else:
self._show_toast(error_msg)
dialog.connect("response", on_response)
dialog.present(self)
self._show_toast(f"New request for '{project.name}'")
def _on_load_request(self, widget, saved_request):
"""Load saved request - smart loading based on current tab state."""