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:
parent
040f2012d7
commit
f70bf4f36d
234
src/window.py
234
src/window.py
@ -98,6 +98,9 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# 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()
|
||||
|
||||
@ -114,6 +117,48 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
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_sourceview_theme(self):
|
||||
"""Set up GtkSourceView theme based on system color scheme."""
|
||||
# Get the style manager to detect dark mode
|
||||
@ -182,6 +227,49 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# 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 _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
|
||||
@ -219,8 +307,13 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
# Create a box for tab label + close button
|
||||
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_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")
|
||||
if tab.id == self.current_tab_id:
|
||||
tab_label_btn.add_css_class("suggested-action")
|
||||
@ -239,13 +332,39 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
self.tab_bar_container.append(tab_box)
|
||||
|
||||
def _close_tab(self, tab_id):
|
||||
"""Close a tab."""
|
||||
# Save current tab state before closing
|
||||
"""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)
|
||||
|
||||
@ -720,12 +839,18 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
row.connect('remove-requested', self._on_header_remove)
|
||||
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."""
|
||||
@ -764,43 +889,44 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
self._load_request_from_entry(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
|
||||
|
||||
# Set method
|
||||
methods = ["GET", "POST", "PUT", "DELETE"]
|
||||
if request.method in methods:
|
||||
self.method_dropdown.set_selected(methods.index(request.method))
|
||||
# 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"
|
||||
|
||||
# Set URL
|
||||
self.url_entry.set_text(request.url)
|
||||
# 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
|
||||
|
||||
# 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_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))
|
||||
# Load into UI
|
||||
self._load_request_to_ui(request)
|
||||
self._update_tab_bar_visibility()
|
||||
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
|
||||
self.request_stack.set_visible_child_name("headers")
|
||||
@ -1021,15 +1147,39 @@ class RosterWindow(Adw.ApplicationWindow):
|
||||
dialog.present(self)
|
||||
|
||||
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
|
||||
|
||||
# Create a new tab with this request
|
||||
self._create_new_tab(
|
||||
name=saved_request.name,
|
||||
request=req,
|
||||
saved_request_id=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 = 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
|
||||
self.request_stack.set_visible_child_name("headers")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user