From f4f33324dd654f9da7b2f0f9f7fd17bfb3573ac3 Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Tue, 12 May 2026 01:10:07 +0200 Subject: [PATCH] Add Copy environment button to Environments dialog Each environment column header now has a copy button that creates a duplicate with a unique name (e.g. "Production (1)"). Non-sensitive variables are copied directly; sensitive variables are migrated through the keyring asynchronously. --- cz.bugsy.roster.json | 2 +- src/environments_dialog.py | 27 ++++++++++ src/project_manager.py | 67 ++++++++++++++++++++++++ src/widgets/environment-column-header.ui | 12 +++++ src/widgets/environment_column_header.py | 7 +++ src/widgets/environment_header_row.py | 6 +++ 6 files changed, 120 insertions(+), 1 deletion(-) diff --git a/cz.bugsy.roster.json b/cz.bugsy.roster.json index 8ac31e4..ae766a5 100644 --- a/cz.bugsy.roster.json +++ b/cz.bugsy.roster.json @@ -35,7 +35,7 @@ "sources" : [ { "type" : "git", - "url" : "file:///home/pavelb/GnomeBuilderProjects/Roster" + "url" : "file:///home/pavelb/Projects/GnomeBuilderProjects/Roster" } ], "config-opts" : [ diff --git a/src/environments_dialog.py b/src/environments_dialog.py index e8c6baf..4616013 100644 --- a/src/environments_dialog.py +++ b/src/environments_dialog.py @@ -18,6 +18,7 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +import re from gi.repository import Adw, Gtk, GObject from .widgets.variable_row import VariableRow from .widgets.environment_row import EnvironmentRow @@ -65,6 +66,8 @@ class EnvironmentsDialog(Adw.Dialog): self._on_environment_edit) self.header_row.connect('environment-delete-requested', self._on_environment_delete) + self.header_row.connect('environment-copy-requested', + self._on_environment_copy) self.table_container.append(self.header_row) # Add separator @@ -249,6 +252,30 @@ class EnvironmentsDialog(Adw.Dialog): dialog.connect("response", on_response) dialog.present(self) + def _on_environment_copy(self, widget, environment): + """Copy an environment with a unique name.""" + existing_names = {env.name for env in self.project.environments} + new_name = self._unique_env_name(environment.name, existing_names) + + def on_copy_complete(new_env): + self._reload_project() + self._populate_table() + self.emit('environments-updated') + + self.project_manager.copy_environment(self.project.id, environment.id, new_name, on_copy_complete) + + @staticmethod + def _unique_env_name(base_name, existing_names): + """Generate a unique environment name by appending (1), (2), etc.""" + match = re.match(r'^(.*?) \((\d+)\)$', base_name) + base = match.group(1) if match else base_name + counter = 1 + while True: + candidate = f"{base} ({counter})" + if candidate not in existing_names: + return candidate + counter += 1 + def _on_environment_delete(self, widget, environment): """Delete environment with confirmation.""" # Prevent deletion of last environment diff --git a/src/project_manager.py b/src/project_manager.py index 27edca7..23c954c 100644 --- a/src/project_manager.py +++ b/src/project_manager.py @@ -229,6 +229,73 @@ class ProjectManager: self.save_projects(projects) return environment + def copy_environment(self, project_id: str, source_env_id: str, new_name: str, + callback: Optional[Callable] = None): + """Copy an environment including all variables (sensitive ones via keyring).""" + projects = self.load_projects() + secret_manager = get_secret_manager() + + for p in projects: + if p.id == project_id: + source_env = next((e for e in p.environments if e.id == source_env_id), None) + if source_env is None: + if callback: + callback(None) + return + + now = datetime.now(timezone.utc).isoformat() + new_env_id = str(uuid.uuid4()) + new_env = Environment( + id=new_env_id, + name=new_name, + variables=dict(source_env.variables), + created_at=now + ) + p.environments.append(new_env) + self.save_projects(projects) + + sensitive_vars = [v for v in p.sensitive_variables if v in source_env.variables] + if not sensitive_vars: + if callback: + callback(new_env) + return + + pending_count = [len(sensitive_vars)] + + def make_copy_handler(var_name, project_name, env_name): + def on_store_done(success=True): + pending_count[0] -= 1 + if pending_count[0] == 0 and callback: + callback(new_env) + + def on_retrieve(value): + if value: + secret_manager.store_secret( + project_id=project_id, + environment_id=new_env_id, + variable_name=var_name, + value=value, + project_name=project_name, + environment_name=env_name, + callback=lambda success: on_store_done(success) + ) + else: + on_store_done() + + return on_retrieve + + for var_name in sensitive_vars: + secret_manager.retrieve_secret( + project_id=project_id, + environment_id=source_env_id, + variable_name=var_name, + callback=make_copy_handler(var_name, p.name, new_name) + ) + return + + if callback: + callback(None) + 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() diff --git a/src/widgets/environment-column-header.ui b/src/widgets/environment-column-header.ui index 714b4de..732e9a0 100644 --- a/src/widgets/environment-column-header.ui +++ b/src/widgets/environment-column-header.ui @@ -23,6 +23,18 @@ center 4 + + + edit-copy-symbolic + Copy environment + + + + + document-properties-symbolic diff --git a/src/widgets/environment_column_header.py b/src/widgets/environment_column_header.py index 9ebe2a9..34abce0 100644 --- a/src/widgets/environment_column_header.py +++ b/src/widgets/environment_column_header.py @@ -28,12 +28,14 @@ class EnvironmentColumnHeader(Gtk.Box): __gtype_name__ = 'EnvironmentColumnHeader' name_label = Gtk.Template.Child() + copy_button = 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, ()), + 'copy-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, environment): @@ -41,6 +43,11 @@ class EnvironmentColumnHeader(Gtk.Box): self.environment = environment self.name_label.set_text(environment.name) + @Gtk.Template.Callback() + def on_copy_clicked(self, button): + """Handle copy button click.""" + self.emit('copy-requested') + @Gtk.Template.Callback() def on_edit_clicked(self, button): """Handle edit button click.""" diff --git a/src/widgets/environment_header_row.py b/src/widgets/environment_header_row.py index 7888416..7836675 100644 --- a/src/widgets/environment_header_row.py +++ b/src/widgets/environment_header_row.py @@ -35,6 +35,7 @@ class EnvironmentHeaderRow(Gtk.Box): __gsignals__ = { 'environment-edit-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)), 'environment-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)), + 'environment-copy-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)), } def __init__(self, environments, size_group): @@ -62,6 +63,7 @@ class EnvironmentHeaderRow(Gtk.Box): header = EnvironmentColumnHeader(env) header.connect('edit-requested', self._on_edit_requested, env) header.connect('delete-requested', self._on_delete_requested, env) + header.connect('copy-requested', self._on_copy_requested, env) # Add to size group for alignment with data rows if self.size_group: @@ -78,6 +80,10 @@ class EnvironmentHeaderRow(Gtk.Box): """Handle delete request from column header.""" self.emit('environment-delete-requested', environment) + def _on_copy_requested(self, widget, environment): + """Handle copy request from column header.""" + self.emit('environment-copy-requested', environment) + def refresh(self, environments): """Refresh with updated environments list.""" self.environments = environments