Compare commits

..

6 Commits

13 changed files with 292 additions and 27 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

@ -5,7 +5,7 @@
<template class="EnvironmentsDialog" parent="AdwDialog">
<property name="title">Manage Environments</property>
<property name="content-width">1100</property>
<property name="content-width">1200</property>
<property name="content-height">600</property>
<property name="follows-content-size">false</property>
@ -18,7 +18,7 @@
</child>
<property name="content">
<object class="GtkScrolledWindow">
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="vexpand">true</property>
<child>
<object class="AdwClamp">

View File

@ -34,6 +34,7 @@ class EnvironmentsDialog(Adw.Dialog):
table_container = Gtk.Template.Child()
add_environment_button = Gtk.Template.Child()
scrolled_window = Gtk.Template.Child()
__gsignals__ = {
'environments-updated': (GObject.SIGNAL_RUN_FIRST, None, ()),
@ -48,6 +49,10 @@ class EnvironmentsDialog(Adw.Dialog):
self.header_row = None
self.data_rows = []
self._populate_table()
self.connect('map', self._scroll_to_start)
def _scroll_to_start(self, widget):
self.scrolled_window.get_hadjustment().set_value(0)
def _populate_table(self):
"""Populate the transposed environment-variable table."""

View File

@ -7,7 +7,6 @@
<property name="title" translatable="yes">Preferences</property>
<property name="modal">True</property>
<property name="default-width">600</property>
<property name="default-height">400</property>
<child>
<object class="AdwPreferencesPage">

View File

@ -48,6 +48,8 @@ def _unique_filename(base: str, exclude: set) -> str:
class ProjectManager:
"""Manages project and saved request persistence."""
_INDEX_FILE = '_index.json'
def __init__(self):
self.data_dir = Path(GLib.get_user_data_dir()) / 'cz.bugsy.roster'
self.projects_dir = self.data_dir / 'projects'
@ -80,8 +82,29 @@ class ProjectManager:
return self._load_from_dirs()
def _load_project_order(self) -> List[str]:
"""Load project order from _index.json. Returns list of project IDs."""
index_file = self.projects_dir / self._INDEX_FILE
if not index_file.exists():
return []
try:
with open(index_file, 'r') as f:
return json.load(f)
except Exception as e:
logger.error("Error loading project order: %s", e)
return []
def _save_project_order(self, project_ids: List[str]):
"""Save project order to _index.json."""
index_file = self.projects_dir / self._INDEX_FILE
try:
with open(index_file, 'w') as f:
json.dump(project_ids, f, indent=2)
except Exception as e:
logger.error("Error saving project order: %s", e)
def _load_from_dirs(self) -> List[Project]:
projects = []
projects_by_id = {}
self._project_dirs.clear()
self._request_filenames.clear()
@ -145,12 +168,24 @@ class ProjectManager:
environments=[Environment.from_dict(e) for e in meta.get('environments', [])],
sensitive_variables=meta.get('sensitive_variables', []),
)
projects.append(project)
projects_by_id[project.id] = project
self._project_dirs[project.id] = proj_dir.name
except Exception as e:
logger.error("Error loading project %s: %s", proj_dir, e)
# Apply saved order, then append any projects not yet in the order file
ordered_ids = self._load_project_order()
projects = []
seen: set = set()
for pid in ordered_ids:
if pid in projects_by_id and pid not in seen:
projects.append(projects_by_id[pid])
seen.add(pid)
for pid, project in projects_by_id.items():
if pid not in seen:
projects.append(project)
self._projects_cache = projects
return projects
@ -299,6 +334,10 @@ class ProjectManager:
)
projects.append(project)
self._save_project(project)
if not self._legacy_mode:
order = self._load_project_order()
order.append(project.id)
self._save_project_order(order)
return project
def update_project(self, project_id: str, new_name: str = None, new_icon: str = None):
@ -332,6 +371,9 @@ class ProjectManager:
if self._legacy_mode:
self._save_all_legacy()
else:
order = self._load_project_order()
self._save_project_order([pid for pid in order if pid != project_id])
def on_secrets_deleted(success):
if callback:
@ -394,6 +436,52 @@ class ProjectManager:
self._save_project(p)
break
def reorder_project(self, project_id: str, direction: int):
"""Move a project up (direction=-1) or down (direction=+1)."""
projects = self.load_projects()
idx = next((i for i, p in enumerate(projects) if p.id == project_id), None)
if idx is None:
return
new_idx = idx + direction
if new_idx < 0 or new_idx >= len(projects):
return
projects[idx], projects[new_idx] = projects[new_idx], projects[idx]
self._projects_cache = projects
if not self._legacy_mode:
self._save_project_order([p.id for p in projects])
def reorder_request(self, project_id: str, request_id: str, direction: int):
"""Move a request up (direction=-1) or down (direction=+1) within its project."""
projects = self.load_projects()
for p in projects:
if p.id != project_id:
continue
idx = next((i for i, r in enumerate(p.requests) if r.id == request_id), None)
if idx is None:
return
new_idx = idx + direction
if new_idx < 0 or new_idx >= len(p.requests):
return
p.requests[idx], p.requests[new_idx] = p.requests[new_idx], p.requests[idx]
self._save_project(p)
return
def move_request_to_project(self, request_id: str, source_project_id: str, target_project_id: str):
"""Move a request from one project to another."""
projects = self.load_projects()
source = next((p for p in projects if p.id == source_project_id), None)
target = next((p for p in projects if p.id == target_project_id), None)
if not source or not target:
return
req = next((r for r in source.requests if r.id == request_id), None)
if not req:
return
# Write to target first so the file is never lost if source save fails
target.requests.append(req)
self._save_project(target)
source.requests = [r for r in source.requests if r.id != request_id]
self._save_project(source)
def add_environment(self, project_id: str, name: str) -> Environment:
projects = self.load_projects()
now = datetime.now(timezone.utc).isoformat()

View File

@ -4,6 +4,16 @@
<requires lib="libadwaita" version="1.0"/>
<menu id="project_menu">
<section>
<item>
<attribute name="label">Move Up</attribute>
<attribute name="action">project.move-up</attribute>
</item>
<item>
<attribute name="label">Move Down</attribute>
<attribute name="action">project.move-down</attribute>
</item>
</section>
<section>
<item>
<attribute name="label">Manage Environments</attribute>

View File

@ -41,11 +41,17 @@ class ProjectItem(Gtk.Box):
'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, ()),
'move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'request-move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
'request-move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, (object,)),
'request-move-to-project-requested': (GObject.SIGNAL_RUN_FIRST, None, (object, GObject.TYPE_STRING)),
}
def __init__(self, project):
def __init__(self, project, other_projects=None):
super().__init__()
self.project = project
self.other_projects = other_projects or []
self.expanded = False
self._setup_actions()
self._populate()
@ -59,6 +65,14 @@ class ProjectItem(Gtk.Box):
"""Setup action group for menu."""
actions = Gio.SimpleActionGroup.new()
self._move_up_action = Gio.SimpleAction.new("move-up", None)
self._move_up_action.connect("activate", lambda *_: self.emit('move-up-requested'))
actions.add_action(self._move_up_action)
self._move_down_action = Gio.SimpleAction.new("move-down", None)
self._move_down_action.connect("activate", lambda *_: self.emit('move-down-requested'))
actions.add_action(self._move_down_action)
action = Gio.SimpleAction.new("manage-environments", None)
action.connect("activate", lambda *_: self.emit('manage-environments-requested'))
actions.add_action(action)
@ -82,16 +96,25 @@ class ProjectItem(Gtk.Box):
self.name_label.set_text(self.project.name)
self.project_icon.set_from_icon_name(self.project.icon)
# Clear and add requests
while child := self.requests_listbox.get_first_child():
self.requests_listbox.remove(child)
for saved_request in self.project.requests:
total = len(self.project.requests)
for i, saved_request in enumerate(self.project.requests):
item = RequestItem(saved_request)
item.set_can_move_up(i > 0)
item.set_can_move_down(i < total - 1)
item.set_other_projects(self.other_projects)
item.connect('load-requested',
lambda w, sr=saved_request: self.emit('request-load-requested', sr))
item.connect('delete-requested',
lambda w, sr=saved_request: self.emit('request-delete-requested', sr))
item.connect('move-up-requested',
lambda w, sr=saved_request: self.emit('request-move-up-requested', sr))
item.connect('move-down-requested',
lambda w, sr=saved_request: self.emit('request-move-down-requested', sr))
item.connect('move-to-project-requested',
lambda w, pid, sr=saved_request: self.emit('request-move-to-project-requested', sr, pid))
self.requests_listbox.append(item)
def _on_header_clicked(self, gesture, n_press, x, y):
@ -99,6 +122,14 @@ class ProjectItem(Gtk.Box):
self.expanded = not self.expanded
self.requests_revealer.set_reveal_child(self.expanded)
def refresh(self):
"""Refresh from project data."""
def set_can_move_up(self, can: bool):
self._move_up_action.set_enabled(can)
def set_can_move_down(self, can: bool):
self._move_down_action.set_enabled(can)
def refresh(self, other_projects=None):
"""Refresh from project data, optionally updating the other_projects list."""
if other_projects is not None:
self.other_projects = other_projects
self._populate()

View File

@ -27,12 +27,12 @@
</child>
<child>
<object class="GtkButton" id="delete_button">
<property name="icon-name">edit-delete-symbolic</property>
<property name="tooltip-text">Delete request</property>
<signal name="clicked" handler="on_delete_clicked"/>
<object class="GtkMenuButton" id="menu_button">
<property name="icon-name">view-more-symbolic</property>
<property name="tooltip-text">Options</property>
<style>
<class name="flat"/>
<class name="request-menu-button"/>
</style>
</object>
</child>

View File

@ -18,7 +18,7 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from gi.repository import Gtk, GObject
from gi.repository import Gtk, GObject, Gio, GLib
_METHOD_CSS = {
'GET': 'accent',
@ -37,10 +37,14 @@ class RequestItem(Gtk.Box):
method_label = Gtk.Template.Child()
name_label = Gtk.Template.Child()
menu_button = Gtk.Template.Child()
__gsignals__ = {
'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'move-up-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'move-down-requested': (GObject.SIGNAL_RUN_FIRST, None, ()),
'move-to-project-requested': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_STRING,)),
}
def __init__(self, saved_request):
@ -56,16 +60,92 @@ class RequestItem(Gtk.Box):
break
self.name_label.set_text(display_name)
# Add click gesture for loading
self._setup_menu()
# Click gesture for loading
gesture = Gtk.GestureClick.new()
gesture.connect('released', self._on_clicked)
self.add_controller(gesture)
# Show/hide menu button on hover
motion = Gtk.EventControllerMotion.new()
motion.connect('enter', self._on_hover_enter)
motion.connect('leave', self._on_hover_leave)
self.add_controller(motion)
def _setup_menu(self):
actions = Gio.SimpleActionGroup.new()
self._move_up_action = Gio.SimpleAction.new("move-up", None)
self._move_up_action.connect("activate", lambda *_: self.emit('move-up-requested'))
actions.add_action(self._move_up_action)
self._move_down_action = Gio.SimpleAction.new("move-down", None)
self._move_down_action.connect("activate", lambda *_: self.emit('move-down-requested'))
actions.add_action(self._move_down_action)
move_to = Gio.SimpleAction.new("move-to-project", GLib.VariantType.new("s"))
move_to.connect("activate", self._on_move_to_project_activated)
actions.add_action(move_to)
delete = Gio.SimpleAction.new("delete", None)
delete.connect("activate", lambda *_: self.emit('delete-requested'))
actions.add_action(delete)
self.insert_action_group("request", actions)
# Build menu model
menu = Gio.Menu()
reorder_section = Gio.Menu()
reorder_section.append("Move Up", "request.move-up")
reorder_section.append("Move Down", "request.move-down")
menu.append_section(None, reorder_section)
self._move_to_submenu = Gio.Menu()
move_section = Gio.Menu()
move_section.append_submenu("Move to Project", self._move_to_submenu)
menu.append_section(None, move_section)
delete_section = Gio.Menu()
delete_section.append("Delete", "request.delete")
menu.append_section(None, delete_section)
self.menu_button.set_menu_model(menu)
# Hidden by default; shown on hover via EventControllerMotion.
# When the popover closes, hide the button again.
self.menu_button.set_opacity(0.0)
popover = self.menu_button.get_popover()
if popover:
popover.connect('closed', lambda p: self.menu_button.set_opacity(0.0))
def _on_move_to_project_activated(self, action, param):
self.emit('move-to-project-requested', param.get_string())
def _on_clicked(self, gesture, n_press, x, y):
"""Handle click to load request."""
"""Load request on row click (but not if the menu button was clicked)."""
alloc = self.menu_button.get_allocation()
if alloc.x <= x <= alloc.x + alloc.width and alloc.y <= y <= alloc.y + alloc.height:
return
self.emit('load-requested')
@Gtk.Template.Callback()
def on_delete_clicked(self, button):
"""Handle delete button click."""
self.emit('delete-requested')
def _on_hover_enter(self, controller, x, y):
self.menu_button.set_opacity(1.0)
def _on_hover_leave(self, controller):
popover = self.menu_button.get_popover()
if not (popover and popover.get_visible()):
self.menu_button.set_opacity(0.0)
def set_can_move_up(self, can: bool):
self._move_up_action.set_enabled(can)
def set_can_move_down(self, can: bool):
self._move_down_action.set_enabled(can)
def set_other_projects(self, projects):
"""Rebuild the 'Move to Project' submenu from the given project list."""
self._move_to_submenu.remove_all()
for p in projects:
self._move_to_submenu.append(p.name, f"request.move-to-project::{p.id}")

View File

@ -961,20 +961,45 @@ class RosterWindow(Adw.ApplicationWindow):
def _load_projects(self) -> None:
"""Load and display projects."""
# Remember which projects are currently expanded.
# GtkListBox wraps each child in a GtkListBoxRow, so we call get_child()
# on the row to reach the actual ProjectItem widget.
expanded_ids = set()
row = self.projects_listbox.get_first_child()
while row:
project_item = row.get_child()
if isinstance(project_item, ProjectItem) and project_item.expanded:
expanded_ids.add(project_item.project.id)
row = row.get_next_sibling()
# Clear existing
while child := self.projects_listbox.get_first_child():
self.projects_listbox.remove(child)
# Load and populate
projects = self.project_manager.load_projects()
for project in projects:
item = ProjectItem(project)
total = len(projects)
for i, project in enumerate(projects):
other_projects = [p for p in projects if p.id != project.id]
item = ProjectItem(project, other_projects)
item.set_can_move_up(i > 0)
item.set_can_move_down(i < total - 1)
item.connect('edit-requested', self._on_project_edit, project)
item.connect('delete-requested', self._on_project_delete, project)
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)
item.connect('move-up-requested', self._on_project_move_up, project)
item.connect('move-down-requested', self._on_project_move_down, project)
item.connect('request-move-up-requested', self._on_request_move_up, project)
item.connect('request-move-down-requested', self._on_request_move_down, project)
item.connect('request-move-to-project-requested', self._on_request_move_to_project, project)
if project.id in expanded_ids:
item.expanded = True
item.requests_revealer.set_transition_duration(0)
item.requests_revealer.set_reveal_child(True)
GLib.idle_add(lambda r=item.requests_revealer: (r.set_transition_duration(250), False)[1])
self.projects_listbox.append(item)
def on_add_project_clicked(self, button):
@ -1112,6 +1137,33 @@ class RosterWindow(Adw.ApplicationWindow):
dialog.connect('environments-updated', on_environments_updated)
dialog.present(self)
def _on_project_move_up(self, widget, project):
self.project_manager.reorder_project(project.id, -1)
self._load_projects()
def _on_project_move_down(self, widget, project):
self.project_manager.reorder_project(project.id, +1)
self._load_projects()
def _on_request_move_up(self, widget, saved_request, project):
self.project_manager.reorder_request(project.id, saved_request.id, -1)
self._load_projects()
def _on_request_move_down(self, widget, saved_request, project):
self.project_manager.reorder_request(project.id, saved_request.id, +1)
self._load_projects()
def _on_request_move_to_project(self, widget, saved_request, target_project_id, source_project):
self.project_manager.move_request_to_project(
saved_request.id, source_project.id, target_project_id
)
self._load_projects()
# Find target project name for toast
projects = self.project_manager.load_projects()
target = next((p for p in projects if p.id == target_project_id), None)
if target:
self._show_toast(f"Moved to '{target.name}'")
@Gtk.Template.Callback()
def on_save_request_clicked(self, button):
"""Save current request to a project."""