From 25b3f9b20acd3870c2f48a2247d02b32b1954644 Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Tue, 2 Jun 2026 17:02:34 +0200 Subject: [PATCH] Add reordering and cross-project move for projects and requests --- src/project_manager.py | 92 ++++++++++++++++++++++++++++++++++++- src/widgets/project-item.ui | 10 ++++ src/widgets/project_item.py | 47 +++++++++++++++---- src/widgets/request-item.ui | 7 ++- src/widgets/request_item.py | 76 ++++++++++++++++++++++++++---- src/window.py | 40 +++++++++++++++- 6 files changed, 248 insertions(+), 24 deletions(-) diff --git a/src/project_manager.py b/src/project_manager.py index 97f24dc..f7441b0 100644 --- a/src/project_manager.py +++ b/src/project_manager.py @@ -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() diff --git a/src/widgets/project-item.ui b/src/widgets/project-item.ui index 5057023..d0bcd4b 100644 --- a/src/widgets/project-item.ui +++ b/src/widgets/project-item.ui @@ -4,6 +4,16 @@ +
+ + Move Up + project.move-up + + + Move Down + project.move-down + +
Manage Environments diff --git a/src/widgets/project_item.py b/src/widgets/project_item.py index 12abf40..3298309 100644 --- a/src/widgets/project_item.py +++ b/src/widgets/project_item.py @@ -6,7 +6,7 @@ # 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 @@ -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)) + 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)) + 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() diff --git a/src/widgets/request-item.ui b/src/widgets/request-item.ui index 573ac6e..af30a71 100644 --- a/src/widgets/request-item.ui +++ b/src/widgets/request-item.ui @@ -27,10 +27,9 @@ - - edit-delete-symbolic - Delete request - + + view-more-symbolic + Options diff --git a/src/widgets/request_item.py b/src/widgets/request_item.py index 149f75a..4f78bd6 100644 --- a/src/widgets/request_item.py +++ b/src/widgets/request_item.py @@ -6,7 +6,7 @@ # 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 @@ -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,72 @@ class RequestItem(Gtk.Box): break self.name_label.set_text(display_name) - # Add click gesture for loading + self._setup_menu() + + # Click gesture for loading (on the whole row, but not on the menu button) gesture = Gtk.GestureClick.new() gesture.connect('released', self._on_clicked) self.add_controller(gesture) + 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) + + 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).""" + # Check if the click was on the menu button — if so, ignore + 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 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}") diff --git a/src/window.py b/src/window.py index e31e930..16337ff 100644 --- a/src/window.py +++ b/src/window.py @@ -967,14 +967,23 @@ class RosterWindow(Adw.ApplicationWindow): # 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) self.projects_listbox.append(item) def on_add_project_clicked(self, button): @@ -1112,6 +1121,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."""