# window.py # # Copyright 2025 Pavel Baksy # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # # SPDX-License-Identifier: GPL-3.0-or-later from gi.repository import 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 .icon_picker_dialog import IconPickerDialog from .widgets.header_row import HeaderRow from .widgets.history_item import HistoryItem from .widgets.project_item import ProjectItem from datetime import datetime import threading @Gtk.Template(resource_path='/cz/vesp/roster/main-window.ui') class RosterWindow(Adw.ApplicationWindow): __gtype_name__ = 'RosterWindow' # Top bar widgets method_dropdown = Gtk.Template.Child() url_entry = Gtk.Template.Child() send_button = Gtk.Template.Child() # 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() # Status bar status_label = Gtk.Template.Child() time_label = Gtk.Template.Child() history_toggle_button = Gtk.Template.Child() # History history_revealer = Gtk.Template.Child() history_listbox = Gtk.Template.Child() hide_history_button = Gtk.Template.Child() def __init__(self, **kwargs): super().__init__(**kwargs) self.http_client = HttpClient() self.history_manager = HistoryManager() self.project_manager = ProjectManager() # Create window actions self._create_actions() # Setup UI self._setup_method_dropdown() self._setup_request_tabs() self._setup_response_tabs() self._load_history() self._load_projects() # Add initial header row self._add_header_row() # Set split pane position to center after window is shown self.connect("map", self._on_window_mapped) def _on_window_mapped(self, widget): """Set split pane position to center when window is mapped.""" # Use idle_add to ensure the widget is fully allocated GLib.idle_add(self._center_split_pane) def _center_split_pane(self): """Center the split pane divider.""" # Get the allocated width of the paned widget allocation = self.split_pane.get_allocation() if allocation.width > 0: self.split_pane.set_position(allocation.width // 2) return False # Don't repeat def _create_actions(self): """Create window-level actions.""" action = Gio.SimpleAction.new("toggle-history", None) action.connect("activate", self.on_toggle_history) self.add_action(action) def _setup_method_dropdown(self): """Populate HTTP method dropdown.""" methods = Gtk.StringList() methods.append("GET") methods.append("POST") methods.append("PUT") methods.append("DELETE") self.method_dropdown.set_model(methods) self.method_dropdown.set_selected(0) # Default to GET def _setup_request_tabs(self): """Create request tabs programmatically.""" # Create stack for switching between pages self.request_stack = Gtk.Stack() self.request_stack.set_vexpand(True) # Create stack switcher for the tabs request_switcher = Gtk.StackSwitcher() request_switcher.set_stack(self.request_stack) request_switcher.set_halign(Gtk.Align.CENTER) # Add switcher and stack to container self.request_tabs_container.append(request_switcher) self.request_tabs_container.append(self.request_stack) # Headers tab headers_scroll = Gtk.ScrolledWindow() self.headers_listbox = Gtk.ListBox() self.headers_listbox.add_css_class("boxed-list") headers_scroll.set_child(self.headers_listbox) # Add header button container headers_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) headers_box.set_margin_start(12) headers_box.set_margin_end(12) headers_box.set_margin_top(12) headers_box.set_margin_bottom(12) headers_box.append(headers_scroll) self.add_header_button = Gtk.Button(label="Add Header") self.add_header_button.connect("clicked", self.on_add_header_clicked) headers_box.append(self.add_header_button) self.request_stack.add_titled(headers_box, "headers", "Headers") # Body tab body_scroll = Gtk.ScrolledWindow() body_scroll.set_vexpand(True) self.body_textview = Gtk.TextView() self.body_textview.set_monospace(True) self.body_textview.set_left_margin(12) self.body_textview.set_right_margin(12) self.body_textview.set_top_margin(12) self.body_textview.set_bottom_margin(12) body_scroll.set_child(self.body_textview) self.request_stack.add_titled(body_scroll, "body", "Body") def _setup_response_tabs(self): """Create response tabs programmatically.""" # Create stack for switching between pages self.response_stack = Gtk.Stack() self.response_stack.set_vexpand(True) # Create stack switcher for the tabs response_switcher = Gtk.StackSwitcher() response_switcher.set_stack(self.response_stack) response_switcher.set_halign(Gtk.Align.CENTER) # Add switcher and stack to container self.response_tabs_container.append(response_switcher) self.response_tabs_container.append(self.response_stack) # Response Headers tab headers_scroll = Gtk.ScrolledWindow() headers_scroll.set_vexpand(True) self.response_headers_textview = Gtk.TextView() self.response_headers_textview.set_editable(False) self.response_headers_textview.set_monospace(True) self.response_headers_textview.set_left_margin(12) self.response_headers_textview.set_right_margin(12) self.response_headers_textview.set_top_margin(12) self.response_headers_textview.set_bottom_margin(12) headers_scroll.set_child(self.response_headers_textview) self.response_stack.add_titled(headers_scroll, "headers", "Headers") # Response Body tab body_scroll = Gtk.ScrolledWindow() body_scroll.set_vexpand(True) self.response_body_textview = Gtk.TextView() self.response_body_textview.set_editable(False) self.response_body_textview.set_monospace(True) self.response_body_textview.set_left_margin(12) self.response_body_textview.set_right_margin(12) self.response_body_textview.set_top_margin(12) self.response_body_textview.set_bottom_margin(12) body_scroll.set_child(self.response_body_textview) self.response_stack.add_titled(body_scroll, "body", "Body") @Gtk.Template.Callback() def on_send_clicked(self, button): """Handle Send button click.""" # Validate URL url = self.url_entry.get_text().strip() if not url: self._show_toast("Please enter a URL") return # Build request from UI request = self._build_request_from_ui() # Disable send button during request self.send_button.set_sensitive(False) self.send_button.set_label("Sending...") self.status_label.set_text("Sending...") self.time_label.set_text("") # Execute in thread to avoid blocking UI thread = threading.Thread(target=self._execute_request_thread, args=(request,)) thread.daemon = True thread.start() def _execute_request_thread(self, request): """Execute request in background thread.""" response, error = self.http_client.execute_request(request) # Update UI in main thread GLib.idle_add(self._handle_response, request, response, error) def _handle_response(self, request, response, error): """Handle response in main thread.""" # Re-enable send button self.send_button.set_sensitive(True) self.send_button.set_label("Send") # Create history entry entry = HistoryEntry( timestamp=datetime.now().isoformat(), request=request, response=response, error=error ) # Save to history self.history_manager.add_entry(entry) # Update UI if response: self._display_response(response) else: self._display_error(error) # Refresh history list self._load_history() def _build_request_from_ui(self): """Build HttpRequest from UI inputs.""" method = self.method_dropdown.get_selected_item().get_string() url = self.url_entry.get_text().strip() # Collect headers headers = {} child = self.headers_listbox.get_first_child() while child is not None: # In GTK4, ListBox wraps children in ListBoxRow, so we need to get the actual child if isinstance(child, Gtk.ListBoxRow): header_row = child.get_child() if isinstance(header_row, HeaderRow): key, value = header_row.get_header() if key and value: headers[key] = value child = child.get_next_sibling() # Get body buffer = self.body_textview.get_buffer() body = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False) return HttpRequest(method=method, url=url, headers=headers, body=body) def _display_response(self, response): """Display response in UI.""" # Update status status_text = f"{response.status_code} {response.status_text}" self.status_label.set_text(status_text) # Update time time_text = f"{response.response_time_ms:.0f} ms" self.time_label.set_text(time_text) # Update response headers buffer = self.response_headers_textview.get_buffer() buffer.set_text(response.headers) # Update response body buffer = self.response_body_textview.get_buffer() buffer.set_text(response.body) def _display_error(self, error): """Display error in UI.""" self.status_label.set_text("Error") self.time_label.set_text("") # Clear response headers buffer = self.response_headers_textview.get_buffer() buffer.set_text("") # Display error in body buffer = self.response_body_textview.get_buffer() buffer.set_text(error) # Show toast self._show_toast(f"Request failed: {error}") def on_add_header_clicked(self, button): """Add new header row.""" self._add_header_row() def _add_header_row(self, key='', value=''): """Add a header row to the list.""" row = HeaderRow() row.set_header(key, value) row.connect('remove-requested', self._on_header_remove) self.headers_listbox.append(row) def _on_header_remove(self, header_row): """Handle header row removal.""" # In GTK4, we need to remove the parent ListBoxRow, not the HeaderRow itself parent = header_row.get_parent() if parent: self.headers_listbox.remove(parent) def _load_history(self): """Load history from file and populate list.""" # Clear existing history items while True: child = self.history_listbox.get_first_child() if child is None: break self.history_listbox.remove(child) # Load and display history entries = self.history_manager.load_history() for entry in entries: item = HistoryItem(entry) item.connect('load-requested', self._on_history_load_requested, entry) self.history_listbox.append(item) def _on_history_load_requested(self, widget, entry): """Handle load request from history item.""" # Show warning dialog dialog = Adw.AlertDialog() dialog.set_heading("Load Request?") dialog.set_body("This will replace your current request. Any unsaved changes will be lost.") dialog.add_response("cancel", "Cancel") dialog.add_response("load", "Load") dialog.set_response_appearance("load", Adw.ResponseAppearance.SUGGESTED) dialog.set_default_response("cancel") dialog.set_close_response("cancel") dialog.connect("response", self._on_load_dialog_response, entry) dialog.present(self) def _on_load_dialog_response(self, dialog, response, entry): """Handle load dialog response.""" if response == "load": self._load_request_from_entry(entry) def _load_request_from_entry(self, entry): """Load request from history entry into UI.""" request = entry.request # Set method methods = ["GET", "POST", "PUT", "DELETE"] if request.method in methods: self.method_dropdown.set_selected(methods.index(request.method)) # Set URL self.url_entry.set_text(request.url) # Clear existing headers while True: child = self.headers_listbox.get_first_child() if child is None: break self.headers_listbox.remove(child) # Set headers for key, value in request.headers.items(): self._add_header_row(key, value) # Add one empty row if no headers if not request.headers: self._add_header_row() # Set body buffer = self.body_textview.get_buffer() buffer.set_text(request.body) # Switch to headers tab self.request_stack.set_visible_child_name("headers") self._show_toast("Request loaded from history") @Gtk.Template.Callback() def on_toggle_history(self, *args): """Toggle history panel visibility (from menu).""" current = self.history_revealer.get_reveal_child() self.history_revealer.set_reveal_child(not current) # Update toggle button state self.history_toggle_button.set_active(not current) # Update hide button icon if not current: self.hide_history_button.set_icon_name("go-down-symbolic") else: self.hide_history_button.set_icon_name("go-up-symbolic") @Gtk.Template.Callback() def on_history_toggle_button_toggled(self, button): """Toggle history panel from bottom button.""" active = button.get_active() self.history_revealer.set_reveal_child(active) # Update hide button icon if active: self.hide_history_button.set_icon_name("go-down-symbolic") else: self.hide_history_button.set_icon_name("go-up-symbolic") def _show_toast(self, message): """Show a toast notification.""" toast = Adw.Toast() toast.set_title(message) toast.set_timeout(3) # 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('edit-requested', self._on_project_edit, project) item.connect('delete-requested', self._on_project_delete, project) item.connect('add-request-requested', self._on_add_to_project, project) item.connect('request-load-requested', self._on_load_request) item.connect('request-delete-requested', self._on_delete_request, project) 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_edit(self, widget, project): """Show edit project dialog with icon picker.""" dialog = Adw.AlertDialog() dialog.set_heading("Edit Project") dialog.set_body(f"Edit '{project.name}':") box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) # Project name entry name_label = Gtk.Label(label="Name:", xalign=0) entry = Gtk.Entry() entry.set_text(project.name) box.append(name_label) box.append(entry) # Icon selector button icon_label = Gtk.Label(label="Icon:", xalign=0) icon_button = Gtk.Button() icon_button.set_child(Gtk.Image.new_from_icon_name(project.icon)) icon_button.selected_icon = project.icon # Store current selection def on_icon_button_clicked(btn): icon_dialog = IconPickerDialog(current_icon=btn.selected_icon) def on_icon_selected(dlg, icon_name): btn.selected_icon = icon_name btn.set_child(Gtk.Image.new_from_icon_name(icon_name)) icon_dialog.connect('icon-selected', on_icon_selected) icon_dialog.present(self) icon_button.connect('clicked', on_icon_button_clicked) box.append(icon_label) box.append(icon_button) 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": new_name = entry.get_text().strip() new_icon = icon_button.selected_icon if new_name: self.project_manager.update_project(project.id, new_name, new_icon) 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)