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 @@ + + + + + + + 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 @@ + + + + + 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 @@
+ + Manage Environments + project.manage-environments + Add Request project.add-request diff --git a/src/widgets/project_item.py b/src/widgets/project_item.py index 61f9ea4..6648f75 100644 --- a/src/widgets/project_item.py +++ b/src/widgets/project_item.py @@ -38,6 +38,7 @@ class ProjectItem(Gtk.Box): 'add-request-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'request-load-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)), 'request-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)), + 'manage-environments-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, project): @@ -56,6 +57,10 @@ class ProjectItem(Gtk.Box): """Setup action group for menu.""" actions = Gio.SimpleActionGroup.new() + action = Gio.SimpleAction.new("manage-environments", None) + action.connect("activate", lambda *_: self.emit('manage-environments-requested')) + actions.add_action(action) + action = Gio.SimpleAction.new("add-request", None) action.connect("activate", lambda *_: self.emit('add-request-requested')) actions.add_action(action) diff --git a/src/widgets/variable-row.ui b/src/widgets/variable-row.ui new file mode 100644 index 0000000..6c23743 --- /dev/null +++ b/src/widgets/variable-row.ui @@ -0,0 +1,30 @@ + + + + + diff --git a/src/widgets/variable_row.py b/src/widgets/variable_row.py new file mode 100644 index 0000000..b8ccb1a --- /dev/null +++ b/src/widgets/variable_row.py @@ -0,0 +1,57 @@ +# variable_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/variable-row.ui') +class VariableRow(Gtk.Box): + """Widget for editing a variable name.""" + + __gtype_name__ = 'VariableRow' + + name_entry = Gtk.Template.Child() + remove_button = Gtk.Template.Child() + + __gsignals__ = { + 'remove-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'changed': (GObject.SIGNAL_RUN_FIRST, None, ()) + } + + def __init__(self, variable_name=""): + super().__init__() + self.name_entry.set_text(variable_name) + self.name_entry.connect('changed', self._on_entry_changed) + + def _on_entry_changed(self, entry): + """Handle changes to name entry.""" + self.emit('changed') + + @Gtk.Template.Callback() + def on_remove_clicked(self, button): + """Handle remove button click.""" + self.emit('remove-requested') + + def get_variable_name(self): + """Return variable name.""" + return self.name_entry.get_text().strip() + + def set_variable_name(self, name: str): + """Set variable name.""" + self.name_entry.set_text(name) diff --git a/src/window.py b/src/window.py index 4e29851..e7cb8f7 100644 --- a/src/window.py +++ b/src/window.py @@ -26,6 +26,7 @@ from .history_manager import HistoryManager from .project_manager import ProjectManager from .tab_manager import TabManager from .icon_picker_dialog import IconPickerDialog +from .environments_dialog import EnvironmentsDialog from .request_tab_widget import RequestTabWidget from .widgets.history_item import HistoryItem from .widgets.project_item import ProjectItem @@ -528,6 +529,7 @@ class RosterWindow(Adw.ApplicationWindow): item.connect('add-request-requested', self._on_add_to_project, project) item.connect('request-load-requested', self._on_load_request) item.connect('request-delete-requested', self._on_delete_request, project) + item.connect('manage-environments-requested', self._on_manage_environments, project) self.projects_listbox.append(item) @Gtk.Template.Callback() @@ -628,6 +630,17 @@ class RosterWindow(Adw.ApplicationWindow): dialog.connect("response", on_response) dialog.present(self) + def _on_manage_environments(self, widget, project): + """Show environments management dialog.""" + dialog = EnvironmentsDialog(project, self.project_manager) + + def on_environments_updated(dlg): + # Reload projects to reflect changes + self._load_projects() + + dialog.connect('environments-updated', on_environments_updated) + dialog.present(self) + @Gtk.Template.Callback() def on_save_request_clicked(self, button): """Save current request to a project."""