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
This commit is contained in:
vesp 2025-12-18 15:16:21 +01:00
parent 2943298a6e
commit a73b838c7f
11 changed files with 826 additions and 54 deletions

View File

@ -72,10 +72,88 @@
</object>
</child>
<!-- Split View: Request (left) | Response (right) -->
<!-- 3-Column Layout: Sidebar | Request | Response -->
<child>
<object class="GtkPaned" id="split_pane">
<object class="GtkPaned" id="main_pane">
<property name="vexpand">True</property>
<property name="orientation">horizontal</property>
<property name="position">250</property>
<property name="shrink-start-child">False</property>
<property name="resize-start-child">True</property>
<!-- START: Sidebar for Projects -->
<property name="start-child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="width-request">200</property>
<!-- Sidebar Header -->
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">6</property>
<property name="margin-bottom">6</property>
<child>
<object class="GtkLabel">
<property name="label">Projects</property>
<property name="xalign">0</property>
<property name="hexpand">True</property>
<style>
<class name="heading"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="add_project_button">
<property name="icon-name">list-add-symbolic</property>
<property name="tooltip-text">Add Project</property>
<signal name="clicked" handler="on_add_project_clicked"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
</object>
</child>
<!-- Projects List -->
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">True</property>
<child>
<object class="GtkListBox" id="projects_listbox">
<style>
<class name="navigation-sidebar"/>
</style>
</object>
</child>
</object>
</child>
<!-- Save Current Request Button -->
<child>
<object class="GtkButton" id="save_request_button">
<property name="label">Save Current Request</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-bottom">12</property>
<signal name="clicked" handler="on_save_request_clicked"/>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</property>
<!-- END: Request/Response Split -->
<property name="end-child">
<object class="GtkPaned" id="split_pane">
<property name="orientation">horizontal</property>
<property name="position">600</property>
<property name="shrink-start-child">False</property>
@ -156,6 +234,8 @@
</object>
</property>
</object>
</property>
</object>
</child>
<!-- History Panel (Collapsible) -->

View File

@ -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')

View File

@ -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']
)

119
src/project_manager.py Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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)

View File

@ -5,5 +5,7 @@
<file preprocess="xml-stripblanks">shortcuts-dialog.ui</file>
<file preprocess="xml-stripblanks">widgets/header-row.ui</file>
<file preprocess="xml-stripblanks">widgets/history-item.ui</file>
<file preprocess="xml-stripblanks">widgets/project-item.ui</file>
<file preprocess="xml-stripblanks">widgets/request-item.ui</file>
</gresource>
</gresources>

View File

@ -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']

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<menu id="project_menu">
<section>
<item>
<attribute name="label">Add Request</attribute>
<attribute name="action">project.add-request</attribute>
</item>
<item>
<attribute name="label">Rename</attribute>
<attribute name="action">project.rename</attribute>
</item>
<item>
<attribute name="label">Delete</attribute>
<attribute name="action">project.delete</attribute>
</item>
</section>
</menu>
<template class="ProjectItem" parent="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">0</property>
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-top">6</property>
<property name="margin-bottom">6</property>
<child>
<object class="GtkImage" id="expand_icon">
<property name="icon-name">go-next-symbolic</property>
</object>
</child>
<child>
<object class="GtkLabel" id="name_label">
<property name="xalign">0</property>
<property name="hexpand">True</property>
<style>
<class name="heading"/>
</style>
</object>
</child>
<child>
<object class="GtkMenuButton">
<property name="icon-name">view-more-symbolic</property>
<property name="menu-model">project_menu</property>
<style>
<class name="flat"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkRevealer" id="requests_revealer">
<property name="reveal-child">False</property>
<property name="transition-type">slide-down</property>
<child>
<object class="GtkListBox" id="requests_listbox">
<property name="margin-start">24</property>
<style>
<class name="navigation-sidebar"/>
</style>
</object>
</child>
</object>
</child>
</template>
</interface>

View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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()

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<template class="RequestItem" parent="GtkBox">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-top">3</property>
<property name="margin-bottom">3</property>
<child>
<object class="GtkLabel" id="method_label">
<property name="width-chars">6</property>
<style>
<class name="caption"/>
<class name="dim-label"/>
</style>
</object>
</child>
<child>
<object class="GtkLabel" id="name_label">
<property name="xalign">0</property>
<property name="ellipsize">end</property>
<property name="hexpand">True</property>
</object>
</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"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
</template>
</interface>

View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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')

View File

@ -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)