From f70bf4f36d6b00ae0e53c61cd582ef5aba16ae2f Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Sun, 21 Dec 2025 00:08:29 +0100 Subject: [PATCH] Add unsaved change tracking and smart tab loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/window.py | 234 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 192 insertions(+), 42 deletions(-) diff --git a/src/window.py b/src/window.py index 4c8a27c..97cc3c2 100644 --- a/src/window.py +++ b/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")