Compare commits

...

4 Commits

Author SHA1 Message Date
60150f63d0 Bump version to 0.8.4 2026-05-12 10:45:25 +02:00
56e294debb Fix variable indicators not updating when active environment is deleted 2026-05-12 01:17:12 +02:00
1a35867871 Refresh environment dropdown in open tabs after environments dialog changes 2026-05-12 01:13:05 +02:00
f4f33324dd 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.
2026-05-12 01:10:07 +02:00
9 changed files with 131 additions and 4 deletions

View File

@ -35,7 +35,7 @@
"sources" : [ "sources" : [
{ {
"type" : "git", "type" : "git",
"url" : "file:///home/pavelb/GnomeBuilderProjects/Roster" "url" : "file:///home/pavelb/Projects/GnomeBuilderProjects/Roster"
} }
], ],
"config-opts" : [ "config-opts" : [

View File

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

View File

@ -18,6 +18,7 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import re
from gi.repository import Adw, Gtk, GObject from gi.repository import Adw, Gtk, GObject
from .widgets.variable_row import VariableRow from .widgets.variable_row import VariableRow
from .widgets.environment_row import EnvironmentRow from .widgets.environment_row import EnvironmentRow
@ -65,6 +66,8 @@ class EnvironmentsDialog(Adw.Dialog):
self._on_environment_edit) self._on_environment_edit)
self.header_row.connect('environment-delete-requested', self.header_row.connect('environment-delete-requested',
self._on_environment_delete) self._on_environment_delete)
self.header_row.connect('environment-copy-requested',
self._on_environment_copy)
self.table_container.append(self.header_row) self.table_container.append(self.header_row)
# Add separator # Add separator
@ -249,6 +252,30 @@ class EnvironmentsDialog(Adw.Dialog):
dialog.connect("response", on_response) dialog.connect("response", on_response)
dialog.present(self) 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): def _on_environment_delete(self, widget, environment):
"""Delete environment with confirmation.""" """Delete environment with confirmation."""
# Prevent deletion of last environment # Prevent deletion of last environment

View File

@ -229,6 +229,73 @@ class ProjectManager:
self.save_projects(projects) self.save_projects(projects)
return environment 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): def update_environment(self, project_id: str, env_id: str, name: str = None, variables: dict = None):
"""Update an environment's name and/or variables.""" """Update an environment's name and/or variables."""
projects = self.load_projects() projects = self.load_projects()

View File

@ -1255,6 +1255,7 @@ class RequestTabWidget(Gtk.Box):
# Set flag to indicate we're programmatically changing the dropdown # Set flag to indicate we're programmatically changing the dropdown
# This prevents the signal handler from triggering indicator updates during initialization # This prevents the signal handler from triggering indicator updates during initialization
self._is_programmatically_changing_environment = True self._is_programmatically_changing_environment = True
old_env_id = self.selected_environment_id
try: try:
# Build string list with "None" + environment names # Build string list with "None" + environment names
@ -1276,6 +1277,8 @@ class RequestTabWidget(Gtk.Box):
index = self.environment_ids.index(self.selected_environment_id) index = self.environment_ids.index(self.selected_environment_id)
self.environment_dropdown.set_selected(index) self.environment_dropdown.set_selected(index)
except ValueError: except ValueError:
# Previously selected environment no longer exists
self.selected_environment_id = None
self.environment_dropdown.set_selected(0) # Default to "None" self.environment_dropdown.set_selected(0) # Default to "None"
else: else:
self.environment_dropdown.set_selected(0) # Default to "None" self.environment_dropdown.set_selected(0) # Default to "None"
@ -1284,8 +1287,9 @@ class RequestTabWidget(Gtk.Box):
# Always clear the flag # Always clear the flag
self._is_programmatically_changing_environment = False self._is_programmatically_changing_environment = False
# Note: Don't update indicators here as the request might not be loaded yet # If the effective environment changed (e.g. was deleted), update indicators
# Indicators will be updated when environment changes or request is loaded if self.selected_environment_id != old_env_id:
self._update_variable_indicators()
def _show_environment_selector(self): def _show_environment_selector(self):
"""Show environment selector (create if it doesn't exist).""" """Show environment selector (create if it doesn't exist)."""

View File

@ -23,6 +23,18 @@
<property name="halign">center</property> <property name="halign">center</property>
<property name="spacing">4</property> <property name="spacing">4</property>
<child>
<object class="GtkButton" id="copy_button">
<property name="icon-name">edit-copy-symbolic</property>
<property name="tooltip-text">Copy environment</property>
<signal name="clicked" handler="on_copy_clicked"/>
<style>
<class name="flat"/>
<class name="circular"/>
</style>
</object>
</child>
<child> <child>
<object class="GtkButton" id="edit_button"> <object class="GtkButton" id="edit_button">
<property name="icon-name">document-properties-symbolic</property> <property name="icon-name">document-properties-symbolic</property>

View File

@ -28,12 +28,14 @@ class EnvironmentColumnHeader(Gtk.Box):
__gtype_name__ = 'EnvironmentColumnHeader' __gtype_name__ = 'EnvironmentColumnHeader'
name_label = Gtk.Template.Child() name_label = Gtk.Template.Child()
copy_button = Gtk.Template.Child()
edit_button = Gtk.Template.Child() edit_button = Gtk.Template.Child()
delete_button = Gtk.Template.Child() delete_button = Gtk.Template.Child()
__gsignals__ = { __gsignals__ = {
'edit-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'edit-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'copy-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
} }
def __init__(self, environment): def __init__(self, environment):
@ -41,6 +43,11 @@ class EnvironmentColumnHeader(Gtk.Box):
self.environment = environment self.environment = environment
self.name_label.set_text(environment.name) 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() @Gtk.Template.Callback()
def on_edit_clicked(self, button): def on_edit_clicked(self, button):
"""Handle edit button click.""" """Handle edit button click."""

View File

@ -35,6 +35,7 @@ class EnvironmentHeaderRow(Gtk.Box):
__gsignals__ = { __gsignals__ = {
'environment-edit-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)), 'environment-edit-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
'environment-delete-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): def __init__(self, environments, size_group):
@ -62,6 +63,7 @@ class EnvironmentHeaderRow(Gtk.Box):
header = EnvironmentColumnHeader(env) header = EnvironmentColumnHeader(env)
header.connect('edit-requested', self._on_edit_requested, env) header.connect('edit-requested', self._on_edit_requested, env)
header.connect('delete-requested', self._on_delete_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 # Add to size group for alignment with data rows
if self.size_group: if self.size_group:
@ -78,6 +80,10 @@ class EnvironmentHeaderRow(Gtk.Box):
"""Handle delete request from column header.""" """Handle delete request from column header."""
self.emit('environment-delete-requested', environment) 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): def refresh(self, environments):
"""Refresh with updated environments list.""" """Refresh with updated environments list."""
self.environments = environments self.environments = environments

View File

@ -1048,6 +1048,10 @@ class RosterWindow(Adw.ApplicationWindow):
def on_environments_updated(dlg): def on_environments_updated(dlg):
# Reload projects to reflect changes # Reload projects to reflect changes
self._load_projects() self._load_projects()
# Refresh environment dropdowns in all open tabs for this project
for page, tab_widget in self.page_to_widget.items():
if tab_widget.project_id == project.id:
tab_widget._populate_environment_dropdown()
dialog.connect('environments-updated', on_environments_updated) dialog.connect('environments-updated', on_environments_updated)
dialog.present(self) dialog.present(self)