Add unsaved change tracking and smart tab loading

Change Tracking:
- Track modifications to URL, method, body, syntax, and headers
- Show • indicator on modified tab labels
- Real-time update of tab state on any change
- Each tab independently tracks its modification state

Unsaved Change Warnings:
- Warn when closing tabs with unsaved changes
- Warn when closing application with any unsaved tabs
- Destructive action styling for close confirmations
- Smart messages: single tab vs multiple tabs

Smart Tab Loading:
- Detect empty "New Request" tabs (no URL, body, or headers)
- Replace empty tabs when loading from sidebar/history
- Create new tab when current tab has changes
- Seamless workflow without unnecessary tabs

Application Close Protection:
- Prevent accidental data loss on app close
- Check all tabs for unsaved changes
- Show confirmation dialog before closing
- Works with all close methods (X, Alt+F4, Quit)

Bug Fixes:
- Fix signal handler to accept variable arguments
- Proper handling of notify::selected signals
This commit is contained in:
Pavel Baksy 2025-12-21 00:08:29 +01:00
parent 040f2012d7
commit f70bf4f36d

View File

@ -98,6 +98,9 @@ class RosterWindow(Adw.ApplicationWindow):
# Set split pane position to center after window is shown # Set split pane position to center after window is shown
self.connect("map", self._on_window_mapped) 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 # Create first tab
self._create_new_tab() self._create_new_tab()
@ -114,6 +117,48 @@ class RosterWindow(Adw.ApplicationWindow):
self.split_pane.set_position(allocation.width // 2) self.split_pane.set_position(allocation.width // 2)
return False # Don't repeat 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_sourceview_theme(self): def _setup_sourceview_theme(self):
"""Set up GtkSourceView theme based on system color scheme.""" """Set up GtkSourceView theme based on system color scheme."""
# Get the style manager to detect dark mode # Get the style manager to detect dark mode
@ -182,6 +227,49 @@ class RosterWindow(Adw.ApplicationWindow):
# Connect new request button # Connect new request button
self.new_request_button.connect("clicked", self._on_new_request_clicked) 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 _create_new_tab(self, name="New Request", request=None, saved_request_id=None): 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 a new tab (simplified version - just clears current request for now)."""
# Create tab in tab manager # Create tab in tab manager
@ -219,8 +307,13 @@ class RosterWindow(Adw.ApplicationWindow):
# Create a box for tab label + close button # Create a box for tab label + close button
tab_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) tab_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
# Tab label with modified indicator
tab_label = tab.name[:20] # Truncate long names
if tab.is_modified():
tab_label += "" # Add dot for unsaved changes
# Tab label button # Tab label button
tab_label_btn = Gtk.Button(label=tab.name[:20]) # Truncate long names tab_label_btn = Gtk.Button(label=tab_label)
tab_label_btn.add_css_class("flat") tab_label_btn.add_css_class("flat")
if tab.id == self.current_tab_id: if tab.id == self.current_tab_id:
tab_label_btn.add_css_class("suggested-action") tab_label_btn.add_css_class("suggested-action")
@ -239,13 +332,39 @@ class RosterWindow(Adw.ApplicationWindow):
self.tab_bar_container.append(tab_box) self.tab_bar_container.append(tab_box)
def _close_tab(self, tab_id): def _close_tab(self, tab_id):
"""Close a tab.""" """Close a tab with unsaved change warning."""
# Save current tab state before closing # Update current tab state
if self.current_tab_id == tab_id: if self.current_tab_id == tab_id:
current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id) current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
if current_tab: if current_tab:
current_tab.request = self._build_request_from_ui() 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 # Close the tab
success = self.tab_manager.close_tab(tab_id) success = self.tab_manager.close_tab(tab_id)
@ -720,12 +839,18 @@ class RosterWindow(Adw.ApplicationWindow):
row.connect('remove-requested', self._on_header_remove) row.connect('remove-requested', self._on_header_remove)
self.headers_listbox.append(row) 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): def _on_header_remove(self, header_row):
"""Handle header row removal.""" """Handle header row removal."""
# In GTK4, we need to remove the parent ListBoxRow, not the HeaderRow itself # In GTK4, we need to remove the parent ListBoxRow, not the HeaderRow itself
parent = header_row.get_parent() parent = header_row.get_parent()
if parent: if parent:
self.headers_listbox.remove(parent) self.headers_listbox.remove(parent)
# Mark as changed
self._on_request_changed(None)
def _load_history(self): def _load_history(self):
"""Load history from file and populate list.""" """Load history from file and populate list."""
@ -764,43 +889,44 @@ class RosterWindow(Adw.ApplicationWindow):
self._load_request_from_entry(entry) self._load_request_from_entry(entry)
def _load_request_from_entry(self, entry): def _load_request_from_entry(self, entry):
"""Load request from history entry into UI.""" """Load request from history entry - smart loading based on current tab state."""
request = entry.request request = entry.request
# Set method # Generate name from method and URL
methods = ["GET", "POST", "PUT", "DELETE"] url_parts = request.url.split('/')
if request.method in methods: url_name = url_parts[-1] if url_parts else request.url
self.method_dropdown.set_selected(methods.index(request.method)) name = f"{request.method} {url_name[:30]}" if url_name else f"{request.method} Request"
# Set URL # Check if current tab is an empty "New Request"
self.url_entry.set_text(request.url) 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
# Clear existing headers # Load into UI
while True: self._load_request_to_ui(request)
child = self.headers_listbox.get_first_child() self._update_tab_bar_visibility()
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_sourceview.get_buffer()
buffer.set_text(request.body)
# Restore syntax selection
syntax_options = ["RAW", "JSON", "XML"]
syntax = getattr(request, 'syntax', 'RAW') # Default to RAW for old requests
if syntax in syntax_options:
self.body_language_dropdown.set_selected(syntax_options.index(syntax))
else: else:
self.body_language_dropdown.set_selected(0) # Default to RAW # 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 # Switch to headers tab
self.request_stack.set_visible_child_name("headers") self.request_stack.set_visible_child_name("headers")
@ -1021,15 +1147,39 @@ class RosterWindow(Adw.ApplicationWindow):
dialog.present(self) dialog.present(self)
def _on_load_request(self, widget, saved_request): def _on_load_request(self, widget, saved_request):
"""Load saved request in new tab.""" """Load saved request - smart loading based on current tab state."""
req = saved_request.request req = saved_request.request
# Create a new tab with this request # Check if current tab is an empty "New Request"
self._create_new_tab( if self._is_empty_new_request_tab():
name=saved_request.name, # Replace the empty tab with this request
request=req, if self.current_tab_id:
saved_request_id=saved_request.id current_tab = self.tab_manager.get_tab_by_id(self.current_tab_id)
) if current_tab:
# Update the tab
current_tab.name = saved_request.name
current_tab.request = req
current_tab.saved_request_id = saved_request.id
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
self._create_new_tab(
name=saved_request.name,
request=req,
saved_request_id=saved_request.id
)
# Switch to headers tab # Switch to headers tab
self.request_stack.set_visible_child_name("headers") self.request_stack.set_visible_child_name("headers")