Remove application title from main header bar and wrap tabs in scrollable container. This prevents window resizing when many tabs are open and follows modern GNOME app patterns.
1474 lines
56 KiB
Python
1474 lines
56 KiB
Python
# 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 <https://www.gnu.org/licenses/>.
|
|
#
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
import gi
|
|
gi.require_version('GtkSource', '5')
|
|
from gi.repository import Adw, Gtk, GLib, Gio, GtkSource
|
|
from .models import HttpRequest, HttpResponse, HistoryEntry, RequestTab
|
|
from .http_client import HttpClient
|
|
from .history_manager import HistoryManager
|
|
from .project_manager import ProjectManager
|
|
from .tab_manager import TabManager
|
|
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 json
|
|
import xml.dom.minidom
|
|
import uuid
|
|
|
|
|
|
@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()
|
|
new_request_button = Gtk.Template.Child()
|
|
tab_bar_scroll = Gtk.Template.Child()
|
|
tab_bar_container = 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
|
|
history_listbox = Gtk.Template.Child()
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
|
|
self.http_client = HttpClient()
|
|
self.history_manager = HistoryManager()
|
|
self.project_manager = ProjectManager()
|
|
self.tab_manager = TabManager()
|
|
|
|
# Tab tracking - maps tab_id to UI state
|
|
self.tab_notebook = Gtk.Notebook()
|
|
self.tab_notebook.set_show_tabs(False) # We'll use custom tab bar
|
|
self.tab_notebook.set_show_border(False)
|
|
self.current_tab_id = None
|
|
|
|
# Create window actions
|
|
self._create_actions()
|
|
|
|
# Setup custom CSS
|
|
self._setup_custom_css()
|
|
|
|
# Setup UI
|
|
self._setup_method_dropdown()
|
|
self._setup_request_tabs()
|
|
self._setup_response_tabs()
|
|
self._setup_tab_system()
|
|
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)
|
|
|
|
# Connect to close-request to warn about unsaved changes
|
|
self.connect("close-request", self._on_close_request)
|
|
|
|
# Create first tab
|
|
self._create_new_tab()
|
|
|
|
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 _on_close_request(self, window):
|
|
"""Handle window close request - warn if there are unsaved changes."""
|
|
# Save current tab state first
|
|
if self.current_tab_id:
|
|
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
|
|
if current_tab:
|
|
current_tab.request = self._build_request_from_ui()
|
|
|
|
# Check if any tabs have unsaved changes
|
|
modified_tabs = [tab for tab in self.tab_manager.tabs if tab.is_modified()]
|
|
|
|
if modified_tabs:
|
|
# Show warning dialog
|
|
dialog = Adw.AlertDialog()
|
|
dialog.set_heading("Unsaved Changes")
|
|
|
|
if len(modified_tabs) == 1:
|
|
dialog.set_body(f"'{modified_tabs[0].name}' has unsaved changes. Close anyway?")
|
|
else:
|
|
dialog.set_body(f"{len(modified_tabs)} requests have unsaved changes. Close anyway?")
|
|
|
|
dialog.add_response("cancel", "Cancel")
|
|
dialog.add_response("close", "Close")
|
|
dialog.set_response_appearance("close", Adw.ResponseAppearance.DESTRUCTIVE)
|
|
dialog.set_default_response("cancel")
|
|
dialog.set_close_response("cancel")
|
|
|
|
dialog.connect("response", self._on_close_app_dialog_response)
|
|
dialog.present(self)
|
|
|
|
# Prevent closing for now - will close if user confirms
|
|
return True
|
|
|
|
# No unsaved changes, allow closing
|
|
return False
|
|
|
|
def _on_close_app_dialog_response(self, dialog, response):
|
|
"""Handle close application dialog response."""
|
|
if response == "close":
|
|
# User confirmed - close the window
|
|
self.destroy()
|
|
|
|
def _setup_custom_css(self):
|
|
"""Setup custom CSS for enhanced tab styling."""
|
|
css_provider = Gtk.CssProvider()
|
|
css_provider.load_from_data(b"""
|
|
/* Document tabs in header bar */
|
|
.tab-button {
|
|
margin: 0 2px;
|
|
padding: 0;
|
|
border-radius: 6px;
|
|
background: alpha(@window_fg_color, 0.08);
|
|
transition: all 200ms ease;
|
|
}
|
|
|
|
.tab-button:hover {
|
|
background: alpha(@window_fg_color, 0.12);
|
|
}
|
|
|
|
.tab-button-active {
|
|
background: alpha(@accent_bg_color, 0.2);
|
|
box-shadow: inset 0 -2px 0 0 @accent_bg_color;
|
|
}
|
|
|
|
.tab-button-active:hover {
|
|
background: alpha(@accent_bg_color, 0.25);
|
|
}
|
|
|
|
/* Tab label button */
|
|
.tab-label-btn {
|
|
padding: 4px 12px;
|
|
min-height: 28px;
|
|
border-radius: 6px 0 0 6px;
|
|
}
|
|
|
|
.tab-button-active .tab-label-btn {
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Tab close button */
|
|
.tab-close-btn {
|
|
padding: 4px 8px;
|
|
min-width: 24px;
|
|
min-height: 24px;
|
|
margin: 2px 4px 2px 0;
|
|
border-radius: 0 6px 6px 0;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.tab-close-btn:hover {
|
|
opacity: 1;
|
|
background: alpha(@window_fg_color, 0.1);
|
|
}
|
|
|
|
.tab-button-active .tab-close-btn:hover {
|
|
background: alpha(@accent_bg_color, 0.3);
|
|
}
|
|
|
|
/* Stack switchers styling (Headers/Body tabs) */
|
|
stackswitcher button {
|
|
padding: 6px 16px;
|
|
min-height: 32px;
|
|
border-radius: 6px;
|
|
margin: 0 2px;
|
|
}
|
|
|
|
stackswitcher button:checked {
|
|
background: @accent_bg_color;
|
|
color: @accent_fg_color;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Add some polish to the tab bar container */
|
|
#tab_bar_container {
|
|
margin: 0 6px;
|
|
}
|
|
""")
|
|
|
|
Gtk.StyleContext.add_provider_for_display(
|
|
self.get_display(),
|
|
css_provider,
|
|
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
|
)
|
|
|
|
def _setup_sourceview_theme(self):
|
|
"""Set up GtkSourceView theme based on system color scheme."""
|
|
# Get the style manager to detect dark mode
|
|
style_manager = Adw.StyleManager.get_default()
|
|
is_dark = style_manager.get_dark()
|
|
|
|
# Get the appropriate color scheme (using classic for more subtle colors)
|
|
# You can change these to customize the color theme:
|
|
# - 'classic' / 'classic-dark' (subtle, muted colors)
|
|
# - 'Adwaita' / 'Adwaita-dark' (GNOME default)
|
|
# - 'tango' (colorful)
|
|
# - 'solarized-light' / 'solarized-dark' (popular alternative)
|
|
# - 'kate' / 'kate-dark'
|
|
style_scheme_manager = GtkSource.StyleSchemeManager.get_default()
|
|
scheme_name = 'Adwaita-dark' if is_dark else 'Adwaita'
|
|
scheme = style_scheme_manager.get_scheme(scheme_name)
|
|
|
|
if scheme:
|
|
self.response_body_sourceview.get_buffer().set_style_scheme(scheme)
|
|
|
|
# Listen for theme changes
|
|
style_manager.connect('notify::dark', self._on_theme_changed)
|
|
|
|
def _on_theme_changed(self, style_manager, param):
|
|
"""Handle system theme changes."""
|
|
is_dark = style_manager.get_dark()
|
|
style_scheme_manager = GtkSource.StyleSchemeManager.get_default()
|
|
scheme_name = 'Adwaita-dark' if is_dark else 'Adwaita'
|
|
scheme = style_scheme_manager.get_scheme(scheme_name)
|
|
|
|
if scheme:
|
|
self.response_body_sourceview.get_buffer().set_style_scheme(scheme)
|
|
# Also update request body theme
|
|
self.body_sourceview.get_buffer().set_style_scheme(scheme)
|
|
|
|
def _setup_request_body_theme(self):
|
|
"""Set up GtkSourceView theme for request body."""
|
|
style_manager = Adw.StyleManager.get_default()
|
|
is_dark = style_manager.get_dark()
|
|
|
|
style_scheme_manager = GtkSource.StyleSchemeManager.get_default()
|
|
scheme_name = 'Adwaita-dark' if is_dark else 'Adwaita'
|
|
scheme = style_scheme_manager.get_scheme(scheme_name)
|
|
|
|
if scheme:
|
|
self.body_sourceview.get_buffer().set_style_scheme(scheme)
|
|
|
|
def _on_request_body_language_changed(self, dropdown, param):
|
|
"""Handle request body language selection change."""
|
|
selected = dropdown.get_selected()
|
|
language_manager = GtkSource.LanguageManager.get_default()
|
|
|
|
buffer = self.body_sourceview.get_buffer()
|
|
|
|
if selected == 0: # RAW
|
|
buffer.set_language(None)
|
|
elif selected == 1: # JSON
|
|
language = language_manager.get_language('json')
|
|
buffer.set_language(language)
|
|
elif selected == 2: # XML
|
|
language = language_manager.get_language('xml')
|
|
buffer.set_language(language)
|
|
|
|
def _setup_tab_system(self):
|
|
"""Set up the tab system."""
|
|
# Connect new request button
|
|
self.new_request_button.connect("clicked", self._on_new_request_clicked)
|
|
|
|
# Connect change tracking to detect modifications
|
|
self.url_entry.connect("changed", self._on_request_changed)
|
|
self.method_dropdown.connect("notify::selected", self._on_request_changed)
|
|
self.body_language_dropdown.connect("notify::selected", self._on_request_changed)
|
|
# Track body changes
|
|
body_buffer = self.body_sourceview.get_buffer()
|
|
body_buffer.connect("changed", self._on_request_changed)
|
|
|
|
def _on_request_changed(self, widget, *args):
|
|
"""Track changes to mark tab as modified."""
|
|
if self.current_tab_id:
|
|
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
|
|
if current_tab:
|
|
# Check if modified
|
|
current_tab.modified = current_tab.is_modified()
|
|
# Update the current request in the tab
|
|
current_tab.request = self._build_request_from_ui()
|
|
# Update tab bar to show modified indicator
|
|
self._update_tab_bar_visibility()
|
|
|
|
def _is_empty_new_request_tab(self):
|
|
"""Check if current tab is an empty 'New Request' tab."""
|
|
if not self.current_tab_id:
|
|
return False
|
|
|
|
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
|
|
if not current_tab:
|
|
return False
|
|
|
|
# Check if it's a "New Request" (not from saved request)
|
|
if current_tab.saved_request_id:
|
|
return False
|
|
|
|
# Check if it's empty (no URL, no body, no headers)
|
|
request = self._build_request_from_ui()
|
|
is_empty = (
|
|
not request.url.strip() and
|
|
not request.body.strip() and
|
|
not request.headers
|
|
)
|
|
|
|
return is_empty
|
|
|
|
def _find_tab_by_saved_request_id(self, saved_request_id):
|
|
"""Find a tab by its saved_request_id. Returns None if not found."""
|
|
for tab in self.tab_manager.tabs:
|
|
if tab.saved_request_id == saved_request_id:
|
|
return tab
|
|
return None
|
|
|
|
def _generate_copy_name(self, base_name):
|
|
"""Generate a unique copy name like 'ReqA (copy)', 'ReqA (copy 2)', etc."""
|
|
# Check if "Name (copy)" exists
|
|
existing_names = {tab.name for tab in self.tab_manager.tabs}
|
|
|
|
copy_name = f"{base_name} (copy)"
|
|
if copy_name not in existing_names:
|
|
return copy_name
|
|
|
|
# Find the highest number used
|
|
counter = 2
|
|
while f"{base_name} (copy {counter})" in existing_names:
|
|
counter += 1
|
|
|
|
return f"{base_name} (copy {counter})"
|
|
|
|
def _switch_to_tab(self, tab_id):
|
|
"""Switch to a specific tab."""
|
|
tab = self.tab_manager.get_tab_by_id(tab_id)
|
|
if not tab:
|
|
return
|
|
|
|
# Update tab manager
|
|
self.tab_manager.set_active_tab(tab_id)
|
|
self.current_tab_id = tab_id
|
|
|
|
# Load the tab's request into UI
|
|
self._load_request_to_ui(tab.request)
|
|
|
|
# Restore response if available
|
|
if tab.response:
|
|
self._display_response(tab.response)
|
|
else:
|
|
# Clear response area
|
|
self.status_label.set_text("Ready")
|
|
self.time_label.set_text("")
|
|
buffer = self.response_headers_textview.get_buffer()
|
|
buffer.set_text("")
|
|
buffer = self.response_body_sourceview.get_buffer()
|
|
buffer.set_text("")
|
|
|
|
# Update tab bar
|
|
self._update_tab_bar_visibility()
|
|
|
|
def _create_new_tab(self, name="New Request", request=None, saved_request_id=None):
|
|
"""Create a new tab (simplified version - just clears current request for now)."""
|
|
# Create tab in tab manager
|
|
if not request:
|
|
request = HttpRequest(method="GET", url="", headers={}, body="", syntax="RAW")
|
|
|
|
tab = self.tab_manager.create_tab(name, request, saved_request_id)
|
|
self.current_tab_id = tab.id
|
|
|
|
# For simplified version, just load this request into UI
|
|
self._load_request_to_ui(request)
|
|
|
|
# Update tab bar visibility
|
|
self._update_tab_bar_visibility()
|
|
|
|
return tab.id
|
|
|
|
def _update_tab_bar_visibility(self):
|
|
"""Show/hide tab bar based on number of tabs."""
|
|
num_tabs = len(self.tab_manager.tabs)
|
|
# Show tab bar only if we have 2 or more tabs
|
|
self.tab_bar_scroll.set_visible(num_tabs >= 2)
|
|
|
|
# Update tab bar with buttons
|
|
if num_tabs >= 2:
|
|
# Clear existing children
|
|
child = self.tab_bar_container.get_first_child()
|
|
while child:
|
|
next_child = child.get_next_sibling()
|
|
self.tab_bar_container.remove(child)
|
|
child = next_child
|
|
|
|
# Add tab buttons with close button
|
|
for tab in self.tab_manager.tabs:
|
|
# Create a box for tab label + close button
|
|
tab_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
|
tab_box.add_css_class("tab-button")
|
|
|
|
# Add active styling if this is the current tab
|
|
if tab.id == self.current_tab_id:
|
|
tab_box.add_css_class("tab-button-active")
|
|
|
|
# Tab label with modified indicator
|
|
tab_label = tab.name[:25] # Truncate long names
|
|
if tab.is_modified():
|
|
tab_label += " •" # Add dot for unsaved changes
|
|
|
|
# Tab label button
|
|
tab_label_btn = Gtk.Button(label=tab_label)
|
|
tab_label_btn.add_css_class("flat")
|
|
tab_label_btn.add_css_class("tab-label-btn")
|
|
tab_label_btn.connect("clicked", lambda btn, tid=tab.id: self._switch_to_tab(tid))
|
|
tab_box.append(tab_label_btn)
|
|
|
|
# Close button
|
|
close_btn = Gtk.Button()
|
|
close_btn.set_icon_name("window-close-symbolic")
|
|
close_btn.add_css_class("flat")
|
|
close_btn.add_css_class("circular")
|
|
close_btn.add_css_class("tab-close-btn")
|
|
close_btn.set_tooltip_text("Close tab")
|
|
close_btn.connect("clicked", lambda btn, tid=tab.id: self._close_tab(tid))
|
|
tab_box.append(close_btn)
|
|
|
|
self.tab_bar_container.append(tab_box)
|
|
|
|
def _close_tab(self, tab_id):
|
|
"""Close a tab with unsaved change warning."""
|
|
# Update current tab state
|
|
if self.current_tab_id == tab_id:
|
|
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
|
|
if current_tab:
|
|
current_tab.request = self._build_request_from_ui()
|
|
|
|
# Check if tab has unsaved changes
|
|
tab = self.tab_manager.get_tab_by_id(tab_id)
|
|
if tab and tab.is_modified():
|
|
# Show warning dialog
|
|
dialog = Adw.AlertDialog()
|
|
dialog.set_heading("Close Unsaved Request?")
|
|
dialog.set_body(f"'{tab.name}' has unsaved changes. Close anyway?")
|
|
dialog.add_response("cancel", "Cancel")
|
|
dialog.add_response("close", "Close")
|
|
dialog.set_response_appearance("close", Adw.ResponseAppearance.DESTRUCTIVE)
|
|
dialog.set_default_response("cancel")
|
|
dialog.set_close_response("cancel")
|
|
|
|
dialog.connect("response", lambda d, r: self._on_close_tab_dialog_response(r, tab_id))
|
|
dialog.present(self)
|
|
else:
|
|
# No unsaved changes, close directly
|
|
self._do_close_tab(tab_id)
|
|
|
|
def _on_close_tab_dialog_response(self, response, tab_id):
|
|
"""Handle close tab dialog response."""
|
|
if response == "close":
|
|
self._do_close_tab(tab_id)
|
|
|
|
def _do_close_tab(self, tab_id):
|
|
"""Actually close the tab (after confirmation if needed)."""
|
|
# Close the tab
|
|
success = self.tab_manager.close_tab(tab_id)
|
|
|
|
if success:
|
|
# If we closed the last tab, create a new one
|
|
if len(self.tab_manager.tabs) == 0:
|
|
self._create_new_tab()
|
|
else:
|
|
# Switch to the active tab (tab_manager already updated active_tab_id)
|
|
active_tab = self.tab_manager.get_active_tab()
|
|
if active_tab:
|
|
self.current_tab_id = active_tab.id
|
|
self._load_request_to_ui(active_tab.request)
|
|
if active_tab.response:
|
|
self._display_response(active_tab.response)
|
|
else:
|
|
# Clear response display
|
|
self.status_label.set_text("Ready")
|
|
self.time_label.set_text("")
|
|
buffer = self.response_headers_textview.get_buffer()
|
|
buffer.set_text("")
|
|
buffer = self.response_body_sourceview.get_buffer()
|
|
buffer.set_text("")
|
|
|
|
# Update tab bar
|
|
self._update_tab_bar_visibility()
|
|
|
|
def _switch_to_tab(self, tab_id):
|
|
"""Switch to a different tab."""
|
|
# Save current tab state
|
|
if self.current_tab_id:
|
|
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
|
|
if current_tab:
|
|
current_tab.request = self._build_request_from_ui()
|
|
|
|
# Load new tab
|
|
tab = self.tab_manager.get_tab_by_id(tab_id)
|
|
if tab:
|
|
self.current_tab_id = tab_id
|
|
self._load_request_to_ui(tab.request)
|
|
if tab.response:
|
|
self._display_response(tab.response)
|
|
self._update_tab_bar_visibility()
|
|
|
|
def _load_request_to_ui(self, request):
|
|
"""Load a request into the UI."""
|
|
# 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 and set headers
|
|
while child := self.headers_listbox.get_first_child():
|
|
self.headers_listbox.remove(child)
|
|
|
|
for key, value in request.headers.items():
|
|
self._add_header_row(key, value)
|
|
|
|
if not request.headers:
|
|
self._add_header_row()
|
|
|
|
# Set body
|
|
buffer = self.body_sourceview.get_buffer()
|
|
buffer.set_text(request.body)
|
|
|
|
# Set syntax
|
|
syntax_options = ["RAW", "JSON", "XML"]
|
|
if request.syntax in syntax_options:
|
|
self.body_language_dropdown.set_selected(syntax_options.index(request.syntax))
|
|
|
|
def _on_new_request_clicked(self, button):
|
|
"""Handle New Request button click."""
|
|
self._create_new_tab()
|
|
|
|
def _create_actions(self):
|
|
"""Create window-level actions."""
|
|
# New tab shortcut (Ctrl+T)
|
|
action = Gio.SimpleAction.new("new-tab", None)
|
|
action.connect("activate", lambda a, p: self._on_new_request_clicked(None))
|
|
self.add_action(action)
|
|
self.get_application().set_accels_for_action("win.new-tab", ["<Control>t"])
|
|
|
|
# Close tab shortcut (Ctrl+W)
|
|
action = Gio.SimpleAction.new("close-tab", None)
|
|
action.connect("activate", lambda a, p: self._close_current_tab())
|
|
self.add_action(action)
|
|
self.get_application().set_accels_for_action("win.close-tab", ["<Control>w"])
|
|
|
|
# Save request shortcut (Ctrl+S)
|
|
action = Gio.SimpleAction.new("save-request", None)
|
|
action.connect("activate", lambda a, p: self.on_save_request_clicked(None))
|
|
self.add_action(action)
|
|
self.get_application().set_accels_for_action("win.save-request", ["<Control>s"])
|
|
|
|
def _close_current_tab(self):
|
|
"""Close the currently active tab."""
|
|
if self.current_tab_id:
|
|
self._close_tab(self.current_tab_id)
|
|
|
|
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)
|
|
self.request_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
|
self.request_stack.set_transition_duration(150)
|
|
|
|
# Create stack switcher for the tabs
|
|
request_switcher = Gtk.StackSwitcher()
|
|
request_switcher.set_stack(self.request_stack)
|
|
request_switcher.set_halign(Gtk.Align.CENTER)
|
|
request_switcher.set_margin_top(8)
|
|
request_switcher.set_margin_bottom(8)
|
|
|
|
# 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()
|
|
headers_scroll.set_vexpand(True) # Expand to fill available space
|
|
headers_scroll.set_min_content_height(150) # Show ~3-4 rows minimum
|
|
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 with syntax highlighting
|
|
body_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
|
|
# SourceView for request body
|
|
body_scroll = Gtk.ScrolledWindow()
|
|
body_scroll.set_vexpand(True)
|
|
self.body_sourceview = GtkSource.View()
|
|
self.body_sourceview.set_editable(True)
|
|
self.body_sourceview.set_show_line_numbers(True)
|
|
self.body_sourceview.set_highlight_current_line(True)
|
|
self.body_sourceview.set_left_margin(12)
|
|
self.body_sourceview.set_right_margin(12)
|
|
self.body_sourceview.set_top_margin(12)
|
|
self.body_sourceview.set_bottom_margin(12)
|
|
|
|
# Apply same font styling as response body
|
|
css_provider = Gtk.CssProvider()
|
|
css_provider.load_from_data(b"""
|
|
textview {
|
|
font-family: "Source Code Pro";
|
|
font-size: 12pt;
|
|
line-height: 1.2;
|
|
}
|
|
""")
|
|
self.body_sourceview.get_style_context().add_provider(
|
|
css_provider,
|
|
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
|
)
|
|
|
|
# Set up theme for request body
|
|
self._setup_request_body_theme()
|
|
|
|
body_scroll.set_child(self.body_sourceview)
|
|
body_box.append(body_scroll)
|
|
|
|
# Bottom toolbar with language selector
|
|
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
toolbar.set_margin_start(12)
|
|
toolbar.set_margin_end(12)
|
|
toolbar.set_margin_top(6)
|
|
toolbar.set_margin_bottom(6)
|
|
|
|
# Spacer to push dropdown to the right
|
|
spacer = Gtk.Box()
|
|
spacer.set_hexpand(True)
|
|
toolbar.append(spacer)
|
|
|
|
# Language selector label and dropdown
|
|
lang_label = Gtk.Label(label="Syntax:")
|
|
toolbar.append(lang_label)
|
|
|
|
lang_list = Gtk.StringList()
|
|
lang_list.append("RAW")
|
|
lang_list.append("JSON")
|
|
lang_list.append("XML")
|
|
|
|
self.body_language_dropdown = Gtk.DropDown(model=lang_list)
|
|
self.body_language_dropdown.set_selected(0) # Default to RAW
|
|
self.body_language_dropdown.connect("notify::selected", self._on_request_body_language_changed)
|
|
toolbar.append(self.body_language_dropdown)
|
|
|
|
body_box.append(toolbar)
|
|
|
|
self.request_stack.add_titled(body_box, "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)
|
|
self.response_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
|
self.response_stack.set_transition_duration(150)
|
|
|
|
# Create stack switcher for the tabs
|
|
response_switcher = Gtk.StackSwitcher()
|
|
response_switcher.set_stack(self.response_stack)
|
|
response_switcher.set_halign(Gtk.Align.CENTER)
|
|
response_switcher.set_margin_top(8)
|
|
response_switcher.set_margin_bottom(8)
|
|
|
|
# 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 with syntax highlighting
|
|
body_scroll = Gtk.ScrolledWindow()
|
|
body_scroll.set_vexpand(True)
|
|
self.response_body_sourceview = GtkSource.View()
|
|
self.response_body_sourceview.set_editable(False)
|
|
self.response_body_sourceview.set_show_line_numbers(True)
|
|
self.response_body_sourceview.set_highlight_current_line(True)
|
|
self.response_body_sourceview.set_left_margin(12)
|
|
self.response_body_sourceview.set_right_margin(12)
|
|
self.response_body_sourceview.set_top_margin(12)
|
|
self.response_body_sourceview.set_bottom_margin(12)
|
|
|
|
# Set up syntax highlighting with system theme colors
|
|
self._setup_sourceview_theme()
|
|
|
|
# Set font family and size
|
|
# You can customize these values:
|
|
# - font-family: Change to any font (e.g., "Monospace", "JetBrains Mono", "Fira Code")
|
|
# - font-size: Change to any size (e.g., 10pt, 12pt, 14pt, 16pt)
|
|
css_provider = Gtk.CssProvider()
|
|
css_provider.load_from_data(b"""
|
|
textview {
|
|
font-family: "Source Code Pro";
|
|
font-size: 12pt;
|
|
line-height: 1.2;
|
|
}
|
|
""")
|
|
self.response_body_sourceview.get_style_context().add_provider(
|
|
css_provider,
|
|
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
|
)
|
|
|
|
body_scroll.set_child(self.response_body_sourceview)
|
|
|
|
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 async (no threading needed!)
|
|
self.http_client.execute_request_async(
|
|
request,
|
|
self._handle_response_callback,
|
|
request # user_data
|
|
)
|
|
|
|
def _handle_response_callback(self, response, error, request):
|
|
"""Callback runs on main thread automatically."""
|
|
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")
|
|
|
|
# Save response to current tab
|
|
if self.current_tab_id:
|
|
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
|
|
if current_tab:
|
|
current_tab.response = response
|
|
|
|
# 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_sourceview.get_buffer()
|
|
body = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False)
|
|
|
|
# Get syntax selection
|
|
syntax_index = self.body_language_dropdown.get_selected()
|
|
syntax_options = ["RAW", "JSON", "XML"]
|
|
syntax = syntax_options[syntax_index]
|
|
|
|
return HttpRequest(method=method, url=url, headers=headers, body=body, syntax=syntax)
|
|
|
|
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)
|
|
|
|
# Extract content-type and format body
|
|
content_type = self._extract_content_type(response.headers)
|
|
formatted_body = self._format_response_body(response.body, content_type)
|
|
|
|
# Update response body with syntax highlighting
|
|
source_buffer = self.response_body_sourceview.get_buffer()
|
|
source_buffer.set_text(formatted_body)
|
|
|
|
# Set language for syntax highlighting
|
|
language = self._get_language_from_content_type(content_type)
|
|
source_buffer.set_language(language)
|
|
|
|
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
|
|
source_buffer = self.response_body_sourceview.get_buffer()
|
|
source_buffer.set_text(error)
|
|
source_buffer.set_language(None) # No syntax highlighting for errors
|
|
|
|
# Show toast
|
|
self._show_toast(f"Request failed: {error}")
|
|
|
|
def _extract_content_type(self, headers_text):
|
|
"""Extract content-type from response headers."""
|
|
if not headers_text:
|
|
return ""
|
|
|
|
# Parse headers line by line
|
|
for line in headers_text.split('\n'):
|
|
line = line.strip()
|
|
if line.lower().startswith('content-type:'):
|
|
# Extract value after colon
|
|
return line.split(':', 1)[1].strip().lower()
|
|
|
|
return ""
|
|
|
|
def _get_language_from_content_type(self, content_type):
|
|
"""Get GtkSourceView language ID from content type."""
|
|
if not content_type:
|
|
return None
|
|
|
|
language_manager = GtkSource.LanguageManager.get_default()
|
|
|
|
# Map content types to language IDs
|
|
if 'application/json' in content_type or 'text/json' in content_type:
|
|
return language_manager.get_language('json')
|
|
elif 'application/xml' in content_type or 'text/xml' in content_type:
|
|
return language_manager.get_language('xml')
|
|
elif 'text/html' in content_type:
|
|
return language_manager.get_language('html')
|
|
elif 'application/javascript' in content_type or 'text/javascript' in content_type:
|
|
return language_manager.get_language('js')
|
|
elif 'text/css' in content_type:
|
|
return language_manager.get_language('css')
|
|
|
|
return None
|
|
|
|
def _format_response_body(self, body, content_type):
|
|
"""Format response body based on content type."""
|
|
if not body or not body.strip():
|
|
return body
|
|
|
|
try:
|
|
# JSON formatting
|
|
if 'application/json' in content_type or 'text/json' in content_type:
|
|
parsed = json.loads(body)
|
|
return json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
|
|
# XML formatting
|
|
elif 'application/xml' in content_type or 'text/xml' in content_type:
|
|
dom = xml.dom.minidom.parseString(body)
|
|
return dom.toprettyxml(indent=" ")
|
|
|
|
except Exception as e:
|
|
# If formatting fails, return original body
|
|
print(f"Failed to format body: {e}")
|
|
|
|
# Return original for other content types or if formatting failed
|
|
return body
|
|
|
|
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)
|
|
row.connect('changed', self._on_request_changed)
|
|
self.headers_listbox.append(row)
|
|
|
|
# Mark as changed if we're adding a non-empty header
|
|
if key or value:
|
|
self._on_request_changed(None)
|
|
|
|
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)
|
|
# Mark as changed
|
|
self._on_request_changed(None)
|
|
|
|
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."""
|
|
# Smart loading: replaces empty tabs or creates new tab if modified
|
|
self._load_request_from_entry(entry)
|
|
|
|
def _load_request_from_entry(self, entry):
|
|
"""Load request from history entry - smart loading based on current tab state."""
|
|
request = entry.request
|
|
|
|
# Generate name from method and URL
|
|
url_parts = request.url.split('/')
|
|
url_name = url_parts[-1] if url_parts else request.url
|
|
name = f"{request.method} {url_name[:30]}" if url_name else f"{request.method} Request"
|
|
|
|
# Check if current tab is an empty "New Request"
|
|
if self._is_empty_new_request_tab():
|
|
# Replace the empty tab with this request
|
|
if self.current_tab_id:
|
|
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
|
|
if current_tab:
|
|
# Update the tab
|
|
current_tab.name = name
|
|
current_tab.request = request
|
|
current_tab.saved_request_id = None # Not from saved requests
|
|
current_tab.original_request = HttpRequest(
|
|
method=request.method,
|
|
url=request.url,
|
|
headers=request.headers.copy(),
|
|
body=request.body,
|
|
syntax=getattr(request, 'syntax', 'RAW')
|
|
)
|
|
current_tab.modified = False
|
|
|
|
# Load into UI
|
|
self._load_request_to_ui(request)
|
|
self._update_tab_bar_visibility()
|
|
else:
|
|
# Current tab has changes or is not a "New Request"
|
|
# Create a new tab
|
|
self._create_new_tab(
|
|
name=name,
|
|
request=request,
|
|
saved_request_id=None
|
|
)
|
|
|
|
# Switch to headers tab
|
|
self.request_stack.set_visible_child_name("headers")
|
|
|
|
self._show_toast("Request loaded from history")
|
|
|
|
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)
|
|
|
|
# Check if current tab has a saved request (for pre-filling)
|
|
current_tab = None
|
|
preselect_project_index = 0
|
|
prefill_name = ""
|
|
if self.current_tab_id:
|
|
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
|
|
if current_tab:
|
|
if current_tab.saved_request_id:
|
|
# Find which project this request belongs to
|
|
for i, p in enumerate(projects):
|
|
for req in p.requests:
|
|
if req.id == current_tab.saved_request_id:
|
|
preselect_project_index = i
|
|
prefill_name = current_tab.name
|
|
break
|
|
elif current_tab.name and current_tab.name != "New Request":
|
|
# Copy tab or named unsaved tab - prefill with tab name
|
|
prefill_name = current_tab.name
|
|
|
|
# Project dropdown
|
|
project_list = Gtk.StringList()
|
|
for p in projects:
|
|
project_list.append(p.name)
|
|
dropdown = Gtk.DropDown(model=project_list)
|
|
dropdown.set_selected(preselect_project_index) # Pre-select project
|
|
box.append(dropdown)
|
|
|
|
# Name entry
|
|
entry = Gtk.Entry()
|
|
entry.set_placeholder_text("Request name")
|
|
if prefill_name:
|
|
entry.set_text(prefill_name) # Pre-fill name
|
|
# Select all text for easy editing
|
|
entry.select_region(0, -1)
|
|
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]
|
|
# Check for duplicate name
|
|
existing = self.project_manager.find_request_by_name(project.id, name)
|
|
if existing:
|
|
# Show overwrite confirmation
|
|
self._show_overwrite_dialog(project, name, existing.id, request)
|
|
else:
|
|
# No duplicate, save normally
|
|
saved_request = self.project_manager.add_request(project.id, name, request)
|
|
self._load_projects()
|
|
self._show_toast(f"Saved as '{name}'")
|
|
|
|
# Clear modified flag on current tab
|
|
self._mark_tab_as_saved(saved_request.id, name, request)
|
|
|
|
dialog.connect("response", on_response)
|
|
dialog.present(self)
|
|
|
|
def _mark_tab_as_saved(self, saved_request_id, name, request):
|
|
"""Mark the current tab as saved (clear modified flag)."""
|
|
if self.current_tab_id:
|
|
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
|
|
if current_tab:
|
|
# Update tab metadata
|
|
current_tab.saved_request_id = saved_request_id
|
|
current_tab.name = name
|
|
current_tab.modified = False
|
|
|
|
# Update original_request to match saved state
|
|
current_tab.original_request = HttpRequest(
|
|
method=request.method,
|
|
url=request.url,
|
|
headers=request.headers.copy(),
|
|
body=request.body,
|
|
syntax=request.syntax
|
|
)
|
|
|
|
# Update tab bar to remove modified indicator
|
|
self._update_tab_bar_visibility()
|
|
|
|
def _show_overwrite_dialog(self, project, name, existing_request_id, request):
|
|
"""Show dialog asking if user wants to overwrite existing request."""
|
|
dialog = Adw.AlertDialog()
|
|
dialog.set_heading("Request Already Exists")
|
|
dialog.set_body(f"A request named '{name}' already exists in '{project.name}'.\n\nDo you want to overwrite it?")
|
|
|
|
dialog.add_response("cancel", "Cancel")
|
|
dialog.add_response("overwrite", "Overwrite")
|
|
dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE)
|
|
dialog.set_default_response("cancel")
|
|
dialog.set_close_response("cancel")
|
|
|
|
def on_overwrite_response(dlg, response):
|
|
if response == "overwrite":
|
|
# Update the existing request
|
|
self.project_manager.update_request(project.id, existing_request_id, name, request)
|
|
self._load_projects()
|
|
self._show_toast(f"Updated '{name}'")
|
|
|
|
# Clear modified flag on current tab
|
|
self._mark_tab_as_saved(existing_request_id, name, request)
|
|
|
|
dialog.connect("response", on_overwrite_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:
|
|
# Check for duplicate name
|
|
existing = self.project_manager.find_request_by_name(project.id, name)
|
|
if existing:
|
|
# Show overwrite confirmation
|
|
self._show_overwrite_dialog(project, name, existing.id, request)
|
|
else:
|
|
# No duplicate, save normally
|
|
saved_request = self.project_manager.add_request(project.id, name, request)
|
|
self._load_projects()
|
|
self._show_toast(f"Saved as '{name}'")
|
|
|
|
# Clear modified flag on current tab
|
|
self._mark_tab_as_saved(saved_request.id, name, request)
|
|
|
|
dialog.connect("response", on_response)
|
|
dialog.present(self)
|
|
|
|
def _on_load_request(self, widget, saved_request):
|
|
"""Load saved request - smart loading based on current tab state."""
|
|
req = saved_request.request
|
|
|
|
# First, check if this request is already open in an unmodified tab
|
|
existing_tab = self._find_tab_by_saved_request_id(saved_request.id)
|
|
if existing_tab and not existing_tab.is_modified():
|
|
# Switch to the existing unmodified tab
|
|
self._switch_to_tab(existing_tab.id)
|
|
self._show_toast(f"Switched to '{saved_request.name}'")
|
|
return
|
|
|
|
# Check if there's a modified tab with this saved_request_id
|
|
# If so, this is a copy and should not be linked
|
|
is_copy = existing_tab and existing_tab.is_modified()
|
|
|
|
if is_copy:
|
|
# Generate numbered copy name
|
|
tab_name = self._generate_copy_name(saved_request.name)
|
|
link_to_saved = None
|
|
else:
|
|
tab_name = saved_request.name
|
|
link_to_saved = saved_request.id
|
|
|
|
# Check if current tab is an empty "New Request"
|
|
if self._is_empty_new_request_tab():
|
|
# Replace the empty tab with this request
|
|
if self.current_tab_id:
|
|
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
|
|
if current_tab:
|
|
# Update the tab
|
|
current_tab.name = tab_name
|
|
current_tab.request = req
|
|
current_tab.saved_request_id = link_to_saved
|
|
|
|
if is_copy:
|
|
# This is a copy - mark as unsaved
|
|
current_tab.original_request = None
|
|
current_tab.modified = True
|
|
else:
|
|
# This is linked to saved request
|
|
current_tab.original_request = HttpRequest(
|
|
method=req.method,
|
|
url=req.url,
|
|
headers=req.headers.copy(),
|
|
body=req.body,
|
|
syntax=req.syntax
|
|
)
|
|
current_tab.modified = False
|
|
|
|
# Load into UI
|
|
self._load_request_to_ui(req)
|
|
self._update_tab_bar_visibility()
|
|
else:
|
|
# Current tab has changes or is not a "New Request"
|
|
# Create a new tab
|
|
tab_id = self._create_new_tab(
|
|
name=tab_name,
|
|
request=req,
|
|
saved_request_id=link_to_saved
|
|
)
|
|
|
|
# If it's a copy, clear the original_request to mark as unsaved
|
|
if is_copy:
|
|
new_tab = self.tab_manager.get_tab_by_id(tab_id)
|
|
if new_tab:
|
|
new_tab.original_request = None
|
|
new_tab.modified = True
|
|
self._update_tab_bar_visibility()
|
|
|
|
# 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)
|