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:
parent
2943298a6e
commit
a73b838c7f
@ -72,10 +72,88 @@
|
|||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
<!-- Split View: Request (left) | Response (right) -->
|
<!-- 3-Column Layout: Sidebar | Request | Response -->
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkPaned" id="split_pane">
|
<object class="GtkPaned" id="main_pane">
|
||||||
<property name="vexpand">True</property>
|
<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="orientation">horizontal</property>
|
||||||
<property name="position">600</property>
|
<property name="position">600</property>
|
||||||
<property name="shrink-start-child">False</property>
|
<property name="shrink-start-child">False</property>
|
||||||
@ -156,6 +234,8 @@
|
|||||||
</object>
|
</object>
|
||||||
</property>
|
</property>
|
||||||
</object>
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|
||||||
<!-- History Panel (Collapsible) -->
|
<!-- History Panel (Collapsible) -->
|
||||||
|
|||||||
@ -33,6 +33,7 @@ roster_sources = [
|
|||||||
'models.py',
|
'models.py',
|
||||||
'http_client.py',
|
'http_client.py',
|
||||||
'history_manager.py',
|
'history_manager.py',
|
||||||
|
'project_manager.py',
|
||||||
]
|
]
|
||||||
|
|
||||||
install_data(roster_sources, install_dir: moduledir)
|
install_data(roster_sources, install_dir: moduledir)
|
||||||
@ -42,6 +43,8 @@ widgets_sources = [
|
|||||||
'widgets/__init__.py',
|
'widgets/__init__.py',
|
||||||
'widgets/header_row.py',
|
'widgets/header_row.py',
|
||||||
'widgets/history_item.py',
|
'widgets/history_item.py',
|
||||||
|
'widgets/project_item.py',
|
||||||
|
'widgets/request_item.py',
|
||||||
]
|
]
|
||||||
|
|
||||||
install_data(widgets_sources, install_dir: moduledir / 'widgets')
|
install_data(widgets_sources, install_dir: moduledir / 'widgets')
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional, List
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -84,3 +84,62 @@ class HistoryEntry:
|
|||||||
response=HttpResponse.from_dict(data['response']) if data.get('response') else None,
|
response=HttpResponse.from_dict(data['response']) if data.get('response') else None,
|
||||||
error=data.get('error')
|
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
119
src/project_manager.py
Normal 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)
|
||||||
@ -5,5 +5,7 @@
|
|||||||
<file preprocess="xml-stripblanks">shortcuts-dialog.ui</file>
|
<file preprocess="xml-stripblanks">shortcuts-dialog.ui</file>
|
||||||
<file preprocess="xml-stripblanks">widgets/header-row.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/history-item.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">widgets/project-item.ui</file>
|
||||||
|
<file preprocess="xml-stripblanks">widgets/request-item.ui</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
|
|||||||
@ -19,5 +19,7 @@
|
|||||||
|
|
||||||
from .header_row import HeaderRow
|
from .header_row import HeaderRow
|
||||||
from .history_item import HistoryItem
|
from .history_item import HistoryItem
|
||||||
|
from .project_item import ProjectItem
|
||||||
|
from .request_item import RequestItem
|
||||||
|
|
||||||
__all__ = ['HeaderRow', 'HistoryItem']
|
__all__ = ['HeaderRow', 'HistoryItem', 'ProjectItem', 'RequestItem']
|
||||||
|
|||||||
80
src/widgets/project-item.ui
Normal file
80
src/widgets/project-item.ui
Normal 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>
|
||||||
98
src/widgets/project_item.py
Normal file
98
src/widgets/project_item.py
Normal 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()
|
||||||
41
src/widgets/request-item.ui
Normal file
41
src/widgets/request-item.ui
Normal 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>
|
||||||
55
src/widgets/request_item.py
Normal file
55
src/widgets/request_item.py
Normal 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')
|
||||||
235
src/window.py
235
src/window.py
@ -21,8 +21,10 @@ from gi.repository import Adw, Gtk, GLib, Gio
|
|||||||
from .models import HttpRequest, HttpResponse, HistoryEntry
|
from .models import HttpRequest, HttpResponse, HistoryEntry
|
||||||
from .http_client import HttpClient
|
from .http_client import HttpClient
|
||||||
from .history_manager import HistoryManager
|
from .history_manager import HistoryManager
|
||||||
|
from .project_manager import ProjectManager
|
||||||
from .widgets.header_row import HeaderRow
|
from .widgets.header_row import HeaderRow
|
||||||
from .widgets.history_item import HistoryItem
|
from .widgets.history_item import HistoryItem
|
||||||
|
from .widgets.project_item import ProjectItem
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
@ -36,9 +38,15 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
url_entry = Gtk.Template.Child()
|
url_entry = Gtk.Template.Child()
|
||||||
send_button = Gtk.Template.Child()
|
send_button = Gtk.Template.Child()
|
||||||
|
|
||||||
# Split pane
|
# Panes
|
||||||
|
main_pane = Gtk.Template.Child()
|
||||||
split_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
|
# Containers for tabs
|
||||||
request_tabs_container = Gtk.Template.Child()
|
request_tabs_container = Gtk.Template.Child()
|
||||||
response_tabs_container = Gtk.Template.Child()
|
response_tabs_container = Gtk.Template.Child()
|
||||||
@ -58,6 +66,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
self.http_client = HttpClient()
|
self.http_client = HttpClient()
|
||||||
self.history_manager = HistoryManager()
|
self.history_manager = HistoryManager()
|
||||||
|
self.project_manager = ProjectManager()
|
||||||
|
|
||||||
# Create window actions
|
# Create window actions
|
||||||
self._create_actions()
|
self._create_actions()
|
||||||
@ -67,6 +76,7 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
self._setup_request_tabs()
|
self._setup_request_tabs()
|
||||||
self._setup_response_tabs()
|
self._setup_response_tabs()
|
||||||
self._load_history()
|
self._load_history()
|
||||||
|
self._load_projects()
|
||||||
|
|
||||||
# Add initial header row
|
# Add initial header row
|
||||||
self._add_header_row()
|
self._add_header_row()
|
||||||
@ -434,3 +444,226 @@ class RosterWindow(Adw.ApplicationWindow):
|
|||||||
# Get the toast overlay (we need to add one)
|
# Get the toast overlay (we need to add one)
|
||||||
# For now, just print to console
|
# For now, just print to console
|
||||||
print(f"Toast: {message}")
|
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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user