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
8cf0770d70
commit
fcad852800
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
|
# 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")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user