From ecc9094514c1726e466b55bdbf6212d16d17da1f Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Thu, 18 Dec 2025 15:16:21 +0100 Subject: [PATCH] Add left sidebar for managing saved requests in projects Implement a 3-column layout with a collapsible sidebar for organizing and managing HTTP requests within projects. Requests are stored in ~/.roster/requests.json for easy git versioning. Features: - Create, rename, and delete projects - Save current request to a project with custom name - Load saved requests with direct click (no confirmation dialog) - Delete saved requests with confirmation - Expandable/collapsible project folders - Context menu for project actions Technical changes: - Add SavedRequest and Project data models with JSON serialization - Implement ProjectManager for persistence to ~/.roster/requests.json - Create RequestItem and ProjectItem widgets with GTK templates - Restructure main window UI to nested GtkPaned (3-column layout) - Add project management dialogs using AdwAlertDialog - Update build system with new source files and UI resources --- src/main-window.ui | 182 ++++++++++++++++++++-------- src/meson.build | 3 + src/models.py | 61 +++++++++- src/project_manager.py | 119 ++++++++++++++++++ src/roster.gresource.xml | 2 + src/widgets/__init__.py | 4 +- src/widgets/project-item.ui | 80 ++++++++++++ src/widgets/project_item.py | 98 +++++++++++++++ src/widgets/request-item.ui | 41 +++++++ src/widgets/request_item.py | 55 +++++++++ src/window.py | 235 +++++++++++++++++++++++++++++++++++- 11 files changed, 826 insertions(+), 54 deletions(-) create mode 100644 src/project_manager.py create mode 100644 src/widgets/project-item.ui create mode 100644 src/widgets/project_item.py create mode 100644 src/widgets/request-item.ui create mode 100644 src/widgets/request_item.py diff --git a/src/main-window.ui b/src/main-window.ui index 58a170e..328e34f 100644 --- a/src/main-window.ui +++ b/src/main-window.ui @@ -72,58 +72,36 @@ - + - + True horizontal - 600 + 250 False - False True - True - + vertical + 200 - - - - vertical - True - - - - - - - - - vertical - - - - - vertical - True - - - - + horizontal - 12 + 6 12 12 6 6 - - Ready + + Projects + 0 + True @@ -131,28 +109,130 @@ - - - - - - - - - True - - - - - - - view-list-symbolic - Toggle History Panel - + + list-add-symbolic + Add Project + + + + + + + True + + + + + + + + + + + + Save Current Request + 12 + 12 + 12 + + + + + + + + + + + horizontal + 600 + False + False + True + True + + + + + vertical + + + + + vertical + True + + + + + + + + + vertical + + + + + vertical + True + + + + + + + horizontal + 12 + 12 + 12 + 6 + 6 + + + + Ready + + + + + + + + + + + + + + True + + + + + + + view-list-symbolic + Toggle History Panel + + + + + + + diff --git a/src/meson.build b/src/meson.build index a2865df..f0d2467 100644 --- a/src/meson.build +++ b/src/meson.build @@ -33,6 +33,7 @@ roster_sources = [ 'models.py', 'http_client.py', 'history_manager.py', + 'project_manager.py', ] install_data(roster_sources, install_dir: moduledir) @@ -42,6 +43,8 @@ widgets_sources = [ 'widgets/__init__.py', 'widgets/header_row.py', 'widgets/history_item.py', + 'widgets/project_item.py', + 'widgets/request_item.py', ] install_data(widgets_sources, install_dir: moduledir / 'widgets') diff --git a/src/models.py b/src/models.py index 822d1e0..6995d94 100644 --- a/src/models.py +++ b/src/models.py @@ -18,7 +18,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from dataclasses import dataclass, asdict -from typing import Dict, Optional +from typing import Dict, Optional, List @dataclass @@ -84,3 +84,62 @@ class HistoryEntry: response=HttpResponse.from_dict(data['response']) if data.get('response') else None, error=data.get('error') ) + + +@dataclass +class SavedRequest: + """A saved HTTP request with metadata.""" + id: str # UUID + name: str # User-provided name + request: HttpRequest + created_at: str # ISO format + modified_at: str # ISO format + + def to_dict(self): + """Convert to dictionary for JSON serialization.""" + return { + 'id': self.id, + 'name': self.name, + 'request': self.request.to_dict(), + 'created_at': self.created_at, + 'modified_at': self.modified_at + } + + @classmethod + def from_dict(cls, data): + """Create instance from dictionary.""" + return cls( + id=data['id'], + name=data['name'], + request=HttpRequest.from_dict(data['request']), + created_at=data['created_at'], + modified_at=data['modified_at'] + ) + + +@dataclass +class Project: + """A project containing saved requests.""" + id: str # UUID + name: str + requests: List[SavedRequest] + created_at: str # ISO format + + def to_dict(self): + """Convert to dictionary for JSON serialization.""" + return { + 'id': self.id, + 'name': self.name, + 'requests': [req.to_dict() for req in self.requests], + 'created_at': self.created_at + } + + @classmethod + def from_dict(cls, data): + """Create instance from dictionary.""" + return cls( + id=data['id'], + name=data['name'], + requests=[SavedRequest.from_dict(r) for r in data.get('requests', [])], + created_at=data['created_at'] + ) diff --git a/src/project_manager.py b/src/project_manager.py new file mode 100644 index 0000000..4503267 --- /dev/null +++ b/src/project_manager.py @@ -0,0 +1,119 @@ +# project_manager.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 + +import json +import uuid +from pathlib import Path +from typing import List +from datetime import datetime, timezone +from .models import Project, SavedRequest, HttpRequest + + +class ProjectManager: + """Manages project and saved request persistence.""" + + def __init__(self): + # Store in ~/.roster/ (not ~/.config/) for git versioning + self.data_dir = Path.home() / '.roster' + self.projects_file = self.data_dir / 'requests.json' + self._ensure_data_dir() + + def _ensure_data_dir(self): + """Create data directory if it doesn't exist.""" + self.data_dir.mkdir(parents=True, exist_ok=True) + + def load_projects(self) -> List[Project]: + """Load projects from JSON file.""" + if not self.projects_file.exists(): + return [] + + try: + with open(self.projects_file, 'r') as f: + data = json.load(f) + return [Project.from_dict(p) for p in data.get('projects', [])] + except Exception as e: + print(f"Error loading projects: {e}") + return [] + + def save_projects(self, projects: List[Project]): + """Save projects to JSON file.""" + try: + data = { + 'version': 1, + 'projects': [p.to_dict() for p in projects] + } + with open(self.projects_file, 'w') as f: + json.dump(data, f, indent=2) + except Exception as e: + print(f"Error saving projects: {e}") + + def add_project(self, name: str) -> Project: + """Create new project.""" + projects = self.load_projects() + project = Project( + id=str(uuid.uuid4()), + name=name, + requests=[], + created_at=datetime.now(timezone.utc).isoformat() + ) + projects.append(project) + self.save_projects(projects) + return project + + def rename_project(self, project_id: str, new_name: str): + """Rename a project.""" + projects = self.load_projects() + for p in projects: + if p.id == project_id: + p.name = new_name + break + self.save_projects(projects) + + def delete_project(self, project_id: str): + """Delete a project and all its requests.""" + projects = self.load_projects() + projects = [p for p in projects if p.id != project_id] + self.save_projects(projects) + + def add_request(self, project_id: str, name: str, request: HttpRequest) -> SavedRequest: + """Add request to a project.""" + projects = self.load_projects() + now = datetime.now(timezone.utc).isoformat() + saved_request = SavedRequest( + id=str(uuid.uuid4()), + name=name, + request=request, + created_at=now, + modified_at=now + ) + for p in projects: + if p.id == project_id: + p.requests.append(saved_request) + break + self.save_projects(projects) + return saved_request + + def delete_request(self, project_id: str, request_id: str): + """Delete a saved request.""" + projects = self.load_projects() + for p in projects: + if p.id == project_id: + p.requests = [r for r in p.requests if r.id != request_id] + break + self.save_projects(projects) diff --git a/src/roster.gresource.xml b/src/roster.gresource.xml index 8d2b115..2da70e7 100644 --- a/src/roster.gresource.xml +++ b/src/roster.gresource.xml @@ -5,5 +5,7 @@ shortcuts-dialog.ui widgets/header-row.ui widgets/history-item.ui + widgets/project-item.ui + widgets/request-item.ui diff --git a/src/widgets/__init__.py b/src/widgets/__init__.py index fcf4c79..afc6ef2 100644 --- a/src/widgets/__init__.py +++ b/src/widgets/__init__.py @@ -19,5 +19,7 @@ from .header_row import HeaderRow from .history_item import HistoryItem +from .project_item import ProjectItem +from .request_item import RequestItem -__all__ = ['HeaderRow', 'HistoryItem'] +__all__ = ['HeaderRow', 'HistoryItem', 'ProjectItem', 'RequestItem'] diff --git a/src/widgets/project-item.ui b/src/widgets/project-item.ui new file mode 100644 index 0000000..ada6ec1 --- /dev/null +++ b/src/widgets/project-item.ui @@ -0,0 +1,80 @@ + + + + + + +
+ + Add Request + project.add-request + + + Rename + project.rename + + + Delete + project.delete + +
+
+ + +
diff --git a/src/widgets/project_item.py b/src/widgets/project_item.py new file mode 100644 index 0000000..5d2a362 --- /dev/null +++ b/src/widgets/project_item.py @@ -0,0 +1,98 @@ +# project_item.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, Gio +from .request_item import RequestItem + + +@Gtk.Template(resource_path='/cz/vesp/roster/widgets/project-item.ui') +class ProjectItem(Gtk.Box): + """Widget for displaying a project with its requests.""" + + __gtype_name__ = 'ProjectItem' + + expand_icon = Gtk.Template.Child() + name_label = Gtk.Template.Child() + requests_revealer = Gtk.Template.Child() + requests_listbox = Gtk.Template.Child() + + __gsignals__ = { + 'rename-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), + '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,)), + } + + def __init__(self, project): + super().__init__() + self.project = project + self.expanded = False + self._setup_actions() + self._populate() + + # Click gesture for header + gesture = Gtk.GestureClick.new() + gesture.connect('released', self._on_header_clicked) + self.add_controller(gesture) + + def _setup_actions(self): + """Setup action group for menu.""" + actions = Gio.SimpleActionGroup.new() + + action = Gio.SimpleAction.new("add-request", None) + action.connect("activate", lambda *_: self.emit('add-request-requested')) + actions.add_action(action) + + action = Gio.SimpleAction.new("rename", None) + action.connect("activate", lambda *_: self.emit('rename-requested')) + actions.add_action(action) + + action = Gio.SimpleAction.new("delete", None) + action.connect("activate", lambda *_: self.emit('delete-requested')) + actions.add_action(action) + + self.insert_action_group("project", actions) + + def _populate(self): + """Populate with project data.""" + self.name_label.set_text(self.project.name) + + # Clear and add requests + while child := self.requests_listbox.get_first_child(): + self.requests_listbox.remove(child) + + for saved_request in self.project.requests: + item = RequestItem(saved_request) + 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)) + self.requests_listbox.append(item) + + def _on_header_clicked(self, gesture, n_press, x, y): + """Toggle expansion.""" + self.expanded = not self.expanded + self.requests_revealer.set_reveal_child(self.expanded) + icon_name = "go-down-symbolic" if self.expanded else "go-next-symbolic" + self.expand_icon.set_from_icon_name(icon_name) + + def refresh(self): + """Refresh from project data.""" + self._populate() diff --git a/src/widgets/request-item.ui b/src/widgets/request-item.ui new file mode 100644 index 0000000..33c453c --- /dev/null +++ b/src/widgets/request-item.ui @@ -0,0 +1,41 @@ + + + + + diff --git a/src/widgets/request_item.py b/src/widgets/request_item.py new file mode 100644 index 0000000..48446fb --- /dev/null +++ b/src/widgets/request_item.py @@ -0,0 +1,55 @@ +# request_item.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/request-item.ui') +class RequestItem(Gtk.Box): + """Widget for displaying a saved request.""" + + __gtype_name__ = 'RequestItem' + + method_label = Gtk.Template.Child() + name_label = Gtk.Template.Child() + + __gsignals__ = { + 'load-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), + } + + def __init__(self, saved_request): + super().__init__() + self.saved_request = saved_request + self.method_label.set_text(saved_request.request.method) + self.name_label.set_text(saved_request.name) + + # Add click gesture for loading + gesture = Gtk.GestureClick.new() + gesture.connect('released', self._on_clicked) + self.add_controller(gesture) + + def _on_clicked(self, gesture, n_press, x, y): + """Handle click to load request.""" + self.emit('load-requested') + + @Gtk.Template.Callback() + def on_delete_clicked(self, button): + """Handle delete button click.""" + self.emit('delete-requested') diff --git a/src/window.py b/src/window.py index 20df2bb..777ebc1 100644 --- a/src/window.py +++ b/src/window.py @@ -21,8 +21,10 @@ from gi.repository import Adw, Gtk, GLib, Gio from .models import HttpRequest, HttpResponse, HistoryEntry from .http_client import HttpClient from .history_manager import HistoryManager +from .project_manager import ProjectManager from .widgets.header_row import HeaderRow from .widgets.history_item import HistoryItem +from .widgets.project_item import ProjectItem from datetime import datetime import threading @@ -36,9 +38,15 @@ class RosterWindow(Adw.ApplicationWindow): url_entry = Gtk.Template.Child() send_button = Gtk.Template.Child() - # Split pane + # Panes + main_pane = Gtk.Template.Child() split_pane = Gtk.Template.Child() + # Sidebar widgets + projects_listbox = Gtk.Template.Child() + add_project_button = Gtk.Template.Child() + save_request_button = Gtk.Template.Child() + # Containers for tabs request_tabs_container = Gtk.Template.Child() response_tabs_container = Gtk.Template.Child() @@ -58,6 +66,7 @@ class RosterWindow(Adw.ApplicationWindow): self.http_client = HttpClient() self.history_manager = HistoryManager() + self.project_manager = ProjectManager() # Create window actions self._create_actions() @@ -67,6 +76,7 @@ class RosterWindow(Adw.ApplicationWindow): self._setup_request_tabs() self._setup_response_tabs() self._load_history() + self._load_projects() # Add initial header row self._add_header_row() @@ -434,3 +444,226 @@ class RosterWindow(Adw.ApplicationWindow): # Get the toast overlay (we need to add one) # For now, just print to console print(f"Toast: {message}") + + # Project Management Methods + + def _load_projects(self): + """Load and display projects.""" + # 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) + item.connect('rename-requested', self._on_project_rename, 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) + self.projects_listbox.append(item) + + @Gtk.Template.Callback() + def on_add_project_clicked(self, button): + """Show dialog to create project.""" + dialog = Adw.AlertDialog() + dialog.set_heading("New Project") + dialog.set_body("Enter a name for the new project:") + + entry = Gtk.Entry() + entry.set_placeholder_text("Project name") + dialog.set_extra_child(entry) + + dialog.add_response("cancel", "Cancel") + dialog.add_response("create", "Create") + dialog.set_response_appearance("create", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("create") + dialog.set_close_response("cancel") + + def on_response(dlg, response): + if response == "create": + name = entry.get_text().strip() + if name: + self.project_manager.add_project(name) + self._load_projects() + + dialog.connect("response", on_response) + dialog.present(self) + + def _on_project_rename(self, widget, project): + """Show rename dialog.""" + dialog = Adw.AlertDialog() + dialog.set_heading("Rename Project") + dialog.set_body(f"Rename '{project.name}' to:") + + entry = Gtk.Entry() + entry.set_text(project.name) + dialog.set_extra_child(entry) + + dialog.add_response("cancel", "Cancel") + dialog.add_response("rename", "Rename") + dialog.set_response_appearance("rename", Adw.ResponseAppearance.SUGGESTED) + + def on_response(dlg, response): + if response == "rename": + new_name = entry.get_text().strip() + if new_name: + self.project_manager.rename_project(project.id, new_name) + self._load_projects() + + dialog.connect("response", on_response) + dialog.present(self) + + def _on_project_delete(self, widget, project): + """Show delete confirmation.""" + dialog = Adw.AlertDialog() + dialog.set_heading("Delete Project?") + dialog.set_body(f"Delete '{project.name}' and all its saved requests? 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_project(project.id) + self._load_projects() + + dialog.connect("response", on_response) + dialog.present(self) + + @Gtk.Template.Callback() + def on_save_request_clicked(self, button): + """Save current request to a project.""" + request = self._build_request_from_ui() + + if not request.url.strip(): + self._show_toast("Cannot save: URL is empty") + return + + projects = self.project_manager.load_projects() + if not projects: + # Show error - no projects + dialog = Adw.AlertDialog() + dialog.set_heading("No Projects") + dialog.set_body("Create a project first before saving requests.") + dialog.add_response("ok", "OK") + dialog.present(self) + return + + # Create save dialog + dialog = Adw.AlertDialog() + dialog.set_heading("Save Request") + dialog.set_body("Choose a project and enter a name:") + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + + # Project dropdown + project_list = Gtk.StringList() + for p in projects: + project_list.append(p.name) + dropdown = Gtk.DropDown(model=project_list) + box.append(dropdown) + + # Name entry + entry = Gtk.Entry() + entry.set_placeholder_text("Request name") + box.append(entry) + + dialog.set_extra_child(box) + + 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": + name = entry.get_text().strip() + if name: + selected = dropdown.get_selected() + project = projects[selected] + self.project_manager.add_request(project.id, name, request) + self._load_projects() + + dialog.connect("response", on_response) + dialog.present(self) + + def _on_add_to_project(self, widget, project): + """Save current request to specific project.""" + request = self._build_request_from_ui() + + if not request.url.strip(): + self._show_toast("Cannot save: URL is empty") + return + + dialog = Adw.AlertDialog() + dialog.set_heading(f"Save to {project.name}") + dialog.set_body("Enter a name for this request:") + + entry = Gtk.Entry() + entry.set_placeholder_text("Request 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": + name = entry.get_text().strip() + if name: + self.project_manager.add_request(project.id, name, request) + self._load_projects() + + dialog.connect("response", on_response) + dialog.present(self) + + def _on_load_request(self, widget, saved_request): + """Load saved request into UI (direct load, no warning).""" + req = saved_request.request + + # Set method + methods = ["GET", "POST", "PUT", "DELETE"] + if req.method in methods: + self.method_dropdown.set_selected(methods.index(req.method)) + + # Set URL + self.url_entry.set_text(req.url) + + # Clear headers + while child := self.headers_listbox.get_first_child(): + self.headers_listbox.remove(child) + + # Set headers + for key, value in req.headers.items(): + self._add_header_row(key, value) + + if not req.headers: + self._add_header_row() + + # Set body + buffer = self.body_textview.get_buffer() + buffer.set_text(req.body) + + # Switch to headers tab + self.request_stack.set_visible_child_name("headers") + + def _on_delete_request(self, widget, saved_request, project): + """Delete saved request with confirmation.""" + dialog = Adw.AlertDialog() + dialog.set_heading("Delete Request?") + dialog.set_body(f"Delete '{saved_request.name}'? This cannot be undone.") + + dialog.add_response("cancel", "Cancel") + dialog.add_response("delete", "Delete") + dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE) + + def on_response(dlg, response): + if response == "delete": + self.project_manager.delete_request(project.id, saved_request.id) + self._load_projects() + + dialog.connect("response", on_response) + dialog.present(self)