diff --git a/src/environments-dialog.ui b/src/environments-dialog.ui
new file mode 100644
index 0000000..090209a
--- /dev/null
+++ b/src/environments-dialog.ui
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+ Manage Environments
+ 700
+ 500
+
+
+
+
+
+
diff --git a/src/environments_dialog.py b/src/environments_dialog.py
new file mode 100644
index 0000000..0a18859
--- /dev/null
+++ b/src/environments_dialog.py
@@ -0,0 +1,233 @@
+# environments_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 .
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from gi.repository import Adw, Gtk, GObject
+from .widgets.variable_row import VariableRow
+from .widgets.environment_row import EnvironmentRow
+
+
+@Gtk.Template(resource_path='/cz/vesp/roster/environments-dialog.ui')
+class EnvironmentsDialog(Adw.Dialog):
+ """Dialog for managing project environments and variables."""
+
+ __gtype_name__ = 'EnvironmentsDialog'
+
+ variables_listbox = Gtk.Template.Child()
+ add_variable_button = Gtk.Template.Child()
+ environments_listbox = Gtk.Template.Child()
+ add_environment_button = Gtk.Template.Child()
+
+ __gsignals__ = {
+ 'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
+ }
+
+ def __init__(self, project, project_manager):
+ super().__init__()
+ self.project = project
+ self.project_manager = project_manager
+ self._populate_variables()
+ self._populate_environments()
+
+ def _populate_variables(self):
+ """Populate variables list."""
+ # Clear existing
+ while child := self.variables_listbox.get_first_child():
+ self.variables_listbox.remove(child)
+
+ # Add variables
+ for var_name in self.project.variable_names:
+ row = VariableRow(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)
+
+ def _populate_environments(self):
+ """Populate environments list."""
+ # Clear existing
+ while child := self.environments_listbox.get_first_child():
+ self.environments_listbox.remove(child)
+
+ # Add environments
+ for env in self.project.environments:
+ row = EnvironmentRow(env, self.project.variable_names)
+ row.connect('edit-requested', self._on_environment_edit, env)
+ row.connect('delete-requested', self._on_environment_delete, env)
+ row.connect('value-changed', self._on_environment_value_changed, env)
+ self.environments_listbox.append(row)
+
+ @Gtk.Template.Callback()
+ def on_add_variable_clicked(self, button):
+ """Add new variable."""
+ dialog = Adw.AlertDialog()
+ dialog.set_heading("New Variable")
+ dialog.set_body("Enter variable name:")
+
+ entry = Gtk.Entry()
+ entry.set_placeholder_text("Variable name")
+ dialog.set_extra_child(entry)
+
+ dialog.add_response("cancel", "Cancel")
+ dialog.add_response("add", "Add")
+ dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)
+ dialog.set_default_response("add")
+ dialog.set_close_response("cancel")
+
+ def on_response(dlg, response):
+ if response == "add":
+ name = entry.get_text().strip()
+ if name and name not in self.project.variable_names:
+ self.project_manager.add_variable(self.project.id, name)
+ # Reload project data
+ self._reload_project()
+ self._populate_variables()
+ self._populate_environments()
+ self.emit('environments-updated')
+
+ dialog.connect("response", on_response)
+ dialog.present(self)
+
+ def _on_variable_remove(self, widget, var_name):
+ """Remove variable with confirmation."""
+ dialog = Adw.AlertDialog()
+ dialog.set_heading("Delete Variable?")
+ dialog.set_body(f"Delete variable '{var_name}' from all environments? This cannot be undone.")
+
+ dialog.add_response("cancel", "Cancel")
+ dialog.add_response("delete", "Delete")
+ dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
+ dialog.set_close_response("cancel")
+
+ def on_response(dlg, response):
+ if response == "delete":
+ self.project_manager.delete_variable(self.project.id, var_name)
+ self._reload_project()
+ self._populate_variables()
+ self._populate_environments()
+ self.emit('environments-updated')
+
+ dialog.connect("response", on_response)
+ dialog.present(self)
+
+ def _on_variable_changed(self, widget, old_name, row):
+ """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_variables()
+ self._populate_environments()
+ self.emit('environments-updated')
+
+ @Gtk.Template.Callback()
+ def on_add_environment_clicked(self, button):
+ """Add new environment."""
+ dialog = Adw.AlertDialog()
+ dialog.set_heading("New Environment")
+ dialog.set_body("Enter environment name:")
+
+ entry = Gtk.Entry()
+ entry.set_placeholder_text("Environment name (e.g., Production)")
+ dialog.set_extra_child(entry)
+
+ dialog.add_response("cancel", "Cancel")
+ dialog.add_response("add", "Add")
+ dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)
+ dialog.set_default_response("add")
+ dialog.set_close_response("cancel")
+
+ def on_response(dlg, response):
+ if response == "add":
+ name = entry.get_text().strip()
+ if name:
+ self.project_manager.add_environment(self.project.id, name)
+ self._reload_project()
+ self._populate_environments()
+ self.emit('environments-updated')
+
+ dialog.connect("response", on_response)
+ dialog.present(self)
+
+ def _on_environment_edit(self, widget, environment):
+ """Edit environment name."""
+ dialog = Adw.AlertDialog()
+ dialog.set_heading("Edit Environment")
+ dialog.set_body("Enter new name:")
+
+ entry = Gtk.Entry()
+ entry.set_text(environment.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":
+ new_name = entry.get_text().strip()
+ if new_name:
+ self.project_manager.update_environment(self.project.id, environment.id, name=new_name)
+ self._reload_project()
+ self._populate_environments()
+ self.emit('environments-updated')
+
+ dialog.connect("response", on_response)
+ dialog.present(self)
+
+ def _on_environment_delete(self, widget, environment):
+ """Delete environment with confirmation."""
+ # Prevent deletion of last environment
+ if len(self.project.environments) <= 1:
+ dialog = Adw.AlertDialog()
+ dialog.set_heading("Cannot Delete")
+ dialog.set_body("Each project must have at least one environment.")
+ dialog.add_response("ok", "OK")
+ dialog.present(self)
+ return
+
+ dialog = Adw.AlertDialog()
+ dialog.set_heading("Delete Environment?")
+ dialog.set_body(f"Delete environment '{environment.name}'? This cannot be undone.")
+
+ dialog.add_response("cancel", "Cancel")
+ dialog.add_response("delete", "Delete")
+ dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
+ dialog.set_close_response("cancel")
+
+ def on_response(dlg, response):
+ if response == "delete":
+ self.project_manager.delete_environment(self.project.id, environment.id)
+ self._reload_project()
+ self._populate_environments()
+ self.emit('environments-updated')
+
+ dialog.connect("response", on_response)
+ dialog.present(self)
+
+ def _on_environment_value_changed(self, widget, var_name, value, environment):
+ """Handle environment variable value change."""
+ self.project_manager.update_environment_variable(self.project.id, environment.id, var_name, value)
+ self.emit('environments-updated')
+
+ def _reload_project(self):
+ """Reload project data from manager."""
+ projects = self.project_manager.load_projects()
+ for p in projects:
+ if p.id == self.project.id:
+ self.project = p
+ break
diff --git a/src/meson.build b/src/meson.build
index 39ad5ec..49562d8 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -37,6 +37,7 @@ roster_sources = [
'tab_manager.py',
'constants.py',
'icon_picker_dialog.py',
+ 'environments_dialog.py',
'preferences_dialog.py',
'request_tab_widget.py',
]
@@ -50,6 +51,8 @@ widgets_sources = [
'widgets/history_item.py',
'widgets/project_item.py',
'widgets/request_item.py',
+ 'widgets/variable_row.py',
+ 'widgets/environment_row.py',
]
install_data(widgets_sources, install_dir: moduledir / 'widgets')
diff --git a/src/models.py b/src/models.py
index a01dae1..22494a8 100644
--- a/src/models.py
+++ b/src/models.py
@@ -121,14 +121,51 @@ class SavedRequest:
)
+@dataclass
+class Environment:
+ """An environment with variable values."""
+ id: str # UUID
+ name: str # e.g., "production", "integration", "stage"
+ variables: Dict[str, str] # variable_name -> value
+ created_at: str # ISO format
+
+ def to_dict(self):
+ """Convert to dictionary for JSON serialization."""
+ return {
+ 'id': self.id,
+ 'name': self.name,
+ 'variables': self.variables,
+ 'created_at': self.created_at
+ }
+
+ @classmethod
+ def from_dict(cls, data):
+ """Create instance from dictionary."""
+ return cls(
+ id=data['id'],
+ name=data['name'],
+ variables=data.get('variables', {}),
+ created_at=data['created_at']
+ )
+
+
@dataclass
class Project:
- """A project containing saved requests."""
+ """A project containing saved requests and environments."""
id: str # UUID
name: str
requests: List[SavedRequest]
created_at: str # ISO format
icon: str = "folder-symbolic" # Icon name
+ variable_names: List[str] = None # List of variable names defined for this project
+ environments: List[Environment] = None # List of environments
+
+ def __post_init__(self):
+ """Initialize optional fields."""
+ if self.variable_names is None:
+ self.variable_names = []
+ if self.environments is None:
+ self.environments = []
def to_dict(self):
"""Convert to dictionary for JSON serialization."""
@@ -137,7 +174,9 @@ class Project:
'name': self.name,
'requests': [req.to_dict() for req in self.requests],
'created_at': self.created_at,
- 'icon': self.icon
+ 'icon': self.icon,
+ 'variable_names': self.variable_names,
+ 'environments': [env.to_dict() for env in self.environments]
}
@classmethod
@@ -148,7 +187,9 @@ class Project:
name=data['name'],
requests=[SavedRequest.from_dict(r) for r in data.get('requests', [])],
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', []),
+ environments=[Environment.from_dict(e) for e in data.get('environments', [])]
)
diff --git a/src/project_manager.py b/src/project_manager.py
index 86022c6..442bcdf 100644
--- a/src/project_manager.py
+++ b/src/project_manager.py
@@ -25,7 +25,7 @@ from datetime import datetime, timezone
import gi
gi.require_version('GLib', '2.0')
from gi.repository import GLib
-from .models import Project, SavedRequest, HttpRequest
+from .models import Project, SavedRequest, HttpRequest, Environment
class ProjectManager:
@@ -69,13 +69,25 @@ class ProjectManager:
print(f"Error saving projects: {e}")
def add_project(self, name: str) -> Project:
- """Create new project."""
+ """Create new project with default environment."""
projects = self.load_projects()
+ now = datetime.now(timezone.utc).isoformat()
+
+ # Create default environment
+ default_env = Environment(
+ id=str(uuid.uuid4()),
+ name="Default",
+ variables={},
+ created_at=now
+ )
+
project = Project(
id=str(uuid.uuid4()),
name=name,
requests=[],
- created_at=datetime.now(timezone.utc).isoformat()
+ created_at=now,
+ variable_names=[],
+ environments=[default_env]
)
projects.append(project)
self.save_projects(projects)
@@ -154,3 +166,104 @@ class ProjectManager:
p.requests = [r for r in p.requests if r.id != request_id]
break
self.save_projects(projects)
+
+ def add_environment(self, project_id: str, name: str) -> Environment:
+ """Add environment to a project."""
+ projects = self.load_projects()
+ now = datetime.now(timezone.utc).isoformat()
+ environment = None
+
+ for p in projects:
+ if p.id == project_id:
+ # Create environment with empty values for all variables
+ variables = {var_name: "" for var_name in p.variable_names}
+ environment = Environment(
+ id=str(uuid.uuid4()),
+ name=name,
+ variables=variables,
+ created_at=now
+ )
+ p.environments.append(environment)
+ break
+
+ self.save_projects(projects)
+ return environment
+
+ def update_environment(self, project_id: str, env_id: str, name: str = None, variables: dict = None):
+ """Update an environment's name and/or variables."""
+ projects = self.load_projects()
+ for p in projects:
+ if p.id == project_id:
+ for env in p.environments:
+ if env.id == env_id:
+ if name is not None:
+ env.name = name
+ if variables is not None:
+ env.variables = variables
+ break
+ break
+ self.save_projects(projects)
+
+ def delete_environment(self, project_id: str, env_id: str):
+ """Delete an environment."""
+ projects = self.load_projects()
+ for p in projects:
+ if p.id == project_id:
+ p.environments = [e for e in p.environments if e.id != env_id]
+ break
+ self.save_projects(projects)
+
+ def add_variable(self, project_id: str, variable_name: str):
+ """Add a variable to the project and all environments."""
+ projects = self.load_projects()
+ for p in projects:
+ if p.id == project_id:
+ if variable_name not in p.variable_names:
+ p.variable_names.append(variable_name)
+ # Add empty value to all environments
+ for env in p.environments:
+ env.variables[variable_name] = ""
+ 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."""
+ projects = self.load_projects()
+ for p in projects:
+ if p.id == project_id:
+ # Update variable_names list
+ if old_name in p.variable_names:
+ idx = p.variable_names.index(old_name)
+ p.variable_names[idx] = 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."""
+ projects = self.load_projects()
+ for p in projects:
+ if p.id == project_id:
+ # Remove from variable_names
+ if variable_name in p.variable_names:
+ p.variable_names.remove(variable_name)
+ # Remove from all environments
+ for env in p.environments:
+ env.variables.pop(variable_name, None)
+ break
+ self.save_projects(projects)
+
+ 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()
+ for p in projects:
+ if p.id == project_id:
+ for env in p.environments:
+ if env.id == env_id:
+ env.variables[variable_name] = value
+ break
+ break
+ self.save_projects(projects)
diff --git a/src/roster.gresource.xml b/src/roster.gresource.xml
index b782723..065e2e8 100644
--- a/src/roster.gresource.xml
+++ b/src/roster.gresource.xml
@@ -5,9 +5,12 @@
shortcuts-dialog.ui
preferences-dialog.ui
icon-picker-dialog.ui
+ environments-dialog.ui
widgets/header-row.ui
widgets/history-item.ui
widgets/project-item.ui
widgets/request-item.ui
+ widgets/variable-row.ui
+ widgets/environment-row.ui
diff --git a/src/widgets/__init__.py b/src/widgets/__init__.py
index afc6ef2..97e06e1 100644
--- a/src/widgets/__init__.py
+++ b/src/widgets/__init__.py
@@ -21,5 +21,7 @@ from .header_row import HeaderRow
from .history_item import HistoryItem
from .project_item import ProjectItem
from .request_item import RequestItem
+from .variable_row import VariableRow
+from .environment_row import EnvironmentRow
-__all__ = ['HeaderRow', 'HistoryItem', 'ProjectItem', 'RequestItem']
+__all__ = ['HeaderRow', 'HistoryItem', 'ProjectItem', 'RequestItem', 'VariableRow', 'EnvironmentRow']
diff --git a/src/widgets/environment-row.ui b/src/widgets/environment-row.ui
new file mode 100644
index 0000000..970e55f
--- /dev/null
+++ b/src/widgets/environment-row.ui
@@ -0,0 +1,52 @@
+
+
+
+
+ horizontal
+ 12
+ 12
+ 12
+ 6
+ 6
+
+
+
+ 0
+ 15
+
+
+
+
+
+
+ horizontal
+ 6
+ True
+
+
+
+
+
+ document-edit-symbolic
+ Edit environment
+
+
+
+
+
+
+
+ edit-delete-symbolic
+ Delete environment
+
+
+
+
+
+
diff --git a/src/widgets/environment_row.py b/src/widgets/environment_row.py
new file mode 100644
index 0000000..1a75622
--- /dev/null
+++ b/src/widgets/environment_row.py
@@ -0,0 +1,86 @@
+# environment_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 .
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from gi.repository import Gtk, GObject
+
+
+@Gtk.Template(resource_path='/cz/vesp/roster/widgets/environment-row.ui')
+class EnvironmentRow(Gtk.Box):
+ """Widget for displaying and editing an environment with its variables."""
+
+ __gtype_name__ = 'EnvironmentRow'
+
+ name_label = Gtk.Template.Child()
+ values_box = 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, ()),
+ 'value-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, str)) # variable_name, value
+ }
+
+ def __init__(self, environment, variable_names):
+ super().__init__()
+ self.environment = environment
+ self.variable_names = variable_names
+ self.value_entries = {}
+ self._populate()
+
+ def _populate(self):
+ """Populate with environment data."""
+ self.name_label.set_text(self.environment.name)
+
+ # Clear values box
+ while child := self.values_box.get_first_child():
+ self.values_box.remove(child)
+
+ # Create entry for each variable
+ for var_name in self.variable_names:
+ entry = Gtk.Entry()
+ entry.set_placeholder_text(var_name)
+ entry.set_text(self.environment.variables.get(var_name, ""))
+ entry.set_hexpand(True)
+ entry.connect('changed', self._on_value_changed, var_name)
+ self.values_box.append(entry)
+ self.value_entries[var_name] = entry
+
+ def _on_value_changed(self, entry, var_name):
+ """Handle value entry changes."""
+ self.emit('value-changed', var_name, entry.get_text())
+
+ @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 refresh(self, variable_names):
+ """Refresh widget with updated variable names."""
+ self.variable_names = variable_names
+ self._populate()
+
+ def get_values(self):
+ """Get all variable values."""
+ return {var_name: entry.get_text() for var_name, entry in self.value_entries.items()}
diff --git a/src/widgets/project-item.ui b/src/widgets/project-item.ui
index 95b4bca..9112877 100644
--- a/src/widgets/project-item.ui
+++ b/src/widgets/project-item.ui
@@ -5,6 +5,10 @@