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
eddaf21a02
commit
ecc9094514
@ -72,58 +72,36 @@
|
||||
</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">600</property>
|
||||
<property name="position">250</property>
|
||||
<property name="shrink-start-child">False</property>
|
||||
<property name="shrink-end-child">False</property>
|
||||
<property name="resize-start-child">True</property>
|
||||
<property name="resize-end-child">True</property>
|
||||
|
||||
<!-- LEFT: Request Configuration -->
|
||||
<!-- START: Sidebar for Projects -->
|
||||
<property name="start-child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="width-request">200</property>
|
||||
|
||||
<!-- Request Tabs Placeholder -->
|
||||
<child>
|
||||
<object class="GtkBox" id="request_tabs_container">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="vexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
|
||||
<!-- RIGHT: Response Display -->
|
||||
<property name="end-child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
|
||||
<!-- Response Tabs Placeholder -->
|
||||
<child>
|
||||
<object class="GtkBox" id="response_tabs_container">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="vexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<!-- Sidebar Header -->
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">12</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" id="status_label">
|
||||
<property name="label">Ready</property>
|
||||
<object class="GtkLabel">
|
||||
<property name="label">Projects</property>
|
||||
<property name="xalign">0</property>
|
||||
<property name="hexpand">True</property>
|
||||
<style>
|
||||
<class name="heading"/>
|
||||
</style>
|
||||
@ -131,28 +109,130 @@
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkLabel" id="time_label">
|
||||
<property name="label"></property>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Spacer -->
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="hexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- History Toggle Button -->
|
||||
<child>
|
||||
<object class="GtkToggleButton" id="history_toggle_button">
|
||||
<property name="icon-name">view-list-symbolic</property>
|
||||
<property name="tooltip-text">Toggle History Panel</property>
|
||||
<signal name="toggled" handler="on_history_toggle_button_toggled"/>
|
||||
<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>
|
||||
<property name="shrink-end-child">False</property>
|
||||
<property name="resize-start-child">True</property>
|
||||
<property name="resize-end-child">True</property>
|
||||
|
||||
<!-- LEFT: Request Configuration -->
|
||||
<property name="start-child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
|
||||
<!-- Request Tabs Placeholder -->
|
||||
<child>
|
||||
<object class="GtkBox" id="request_tabs_container">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="vexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
|
||||
<!-- RIGHT: Response Display -->
|
||||
<property name="end-child">
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">vertical</property>
|
||||
|
||||
<!-- Response Tabs Placeholder -->
|
||||
<child>
|
||||
<object class="GtkBox" id="response_tabs_container">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="vexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">12</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" id="status_label">
|
||||
<property name="label">Ready</property>
|
||||
<style>
|
||||
<class name="heading"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<child>
|
||||
<object class="GtkLabel" id="time_label">
|
||||
<property name="label"></property>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- Spacer -->
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="hexpand">True</property>
|
||||
</object>
|
||||
</child>
|
||||
|
||||
<!-- History Toggle Button -->
|
||||
<child>
|
||||
<object class="GtkToggleButton" id="history_toggle_button">
|
||||
<property name="icon-name">view-list-symbolic</property>
|
||||
<property name="tooltip-text">Toggle History Panel</property>
|
||||
<signal name="toggled" handler="on_history_toggle_button_toggled"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
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">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>
|
||||
|
||||
@ -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']
|
||||
|
||||
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 .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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user