pomera-ai-commander 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,13 @@ import logging
12
12
  import time
13
13
  from typing import Optional, Tuple, Dict, Any, List
14
14
 
15
+ # Import diff utilities for preview
16
+ try:
17
+ from core.diff_utils import generate_find_replace_preview, FindReplacePreview
18
+ DIFF_UTILS_AVAILABLE = True
19
+ except ImportError:
20
+ DIFF_UTILS_AVAILABLE = False
21
+
15
22
  # Import optimized components if available
16
23
  try:
17
24
  from core.optimized_search_highlighter import get_search_highlighter, OptimizedSearchHighlighter, HighlightMode
@@ -29,6 +36,16 @@ try:
29
36
  except ImportError:
30
37
  AI_TOOLS_AVAILABLE = False
31
38
 
39
+ # Import Memento pattern for undo/redo functionality
40
+ try:
41
+ from core.memento import (
42
+ FindReplaceMemento, MementoCaretaker, TextState,
43
+ capture_text_state, restore_text_state
44
+ )
45
+ MEMENTO_AVAILABLE = True
46
+ except ImportError:
47
+ MEMENTO_AVAILABLE = False
48
+
32
49
 
33
50
  class FindReplaceWidget:
34
51
  """
@@ -79,8 +96,17 @@ class FindReplaceWidget:
79
96
  self.skipped_matches = set()
80
97
  self.all_matches_processed = False
81
98
  self.loop_start_position = None
99
+
100
+ # Undo/Redo system using Memento pattern
101
+ if MEMENTO_AVAILABLE:
102
+ self.memento_caretaker = MementoCaretaker(max_history=50)
103
+ self.memento_caretaker.add_change_callback(self._on_undo_redo_change)
104
+ else:
105
+ self.memento_caretaker = None
106
+ # Legacy undo stack for fallback
82
107
  self.undo_stack = [] # For undo functionality
83
108
  self.max_undo_stack = 10 # Limit undo history
109
+
84
110
 
85
111
  # UI components (will be created by create_widgets)
86
112
  self.find_text_field = None
@@ -227,6 +253,12 @@ class FindReplaceWidget:
227
253
  replace_buttons_frame2.pack(fill=tk.X, pady=2)
228
254
  self.undo_button = ttk.Button(replace_buttons_frame2, text="Undo", command=self.undo_replace_all, state="disabled")
229
255
  self.undo_button.pack(side=tk.LEFT, padx=5)
256
+ self.redo_button = ttk.Button(replace_buttons_frame2, text="Redo", command=self.redo_replace_all, state="disabled")
257
+ self.redo_button.pack(side=tk.LEFT, padx=5)
258
+ # Preview Diff button - shows unified diff before Replace All
259
+ if DIFF_UTILS_AVAILABLE:
260
+ ttk.Button(replace_buttons_frame2, text="Preview Diff", command=self.show_diff_preview).pack(side=tk.LEFT, padx=5)
261
+
230
262
 
231
263
  # Initialize search state
232
264
  self.current_match_index = 0
@@ -382,6 +414,12 @@ class FindReplaceWidget:
382
414
  del self._regex_cache[key]
383
415
 
384
416
  if self.regex_mode_var.get():
417
+ # Validate regex before using it
418
+ is_valid, error_msg, suggestion = self._validate_regex(find_str)
419
+ if not is_valid:
420
+ # Return empty pattern for invalid regex - caller should check validation first
421
+ self.logger.warning(f"Invalid regex pattern: {error_msg}")
422
+ return ""
385
423
  pattern = find_str
386
424
  else:
387
425
  search_term = re.escape(find_str)
@@ -415,6 +453,51 @@ class FindReplaceWidget:
415
453
  result = result.replace(escape, char)
416
454
 
417
455
  return result
456
+
457
+ def _validate_regex(self, pattern: str) -> Tuple[bool, str, str]:
458
+ """
459
+ Validate a regex pattern before execution.
460
+
461
+ Args:
462
+ pattern: The regex pattern string to validate
463
+
464
+ Returns:
465
+ Tuple of (is_valid, error_message, suggestion)
466
+ If valid: (True, "", "")
467
+ If invalid: (False, error_message, helpful_suggestion)
468
+ """
469
+ if not pattern:
470
+ return True, "", ""
471
+
472
+ try:
473
+ compiled = re.compile(pattern)
474
+ # Return info about groups for debugging
475
+ return True, "", ""
476
+ except re.error as e:
477
+ error_msg = str(e)
478
+ suggestion = self._get_regex_error_help(error_msg)
479
+ return False, error_msg, suggestion
480
+
481
+ def validate_current_pattern(self) -> Tuple[bool, str]:
482
+ """
483
+ Validate the current find pattern in the UI.
484
+
485
+ Returns:
486
+ Tuple of (is_valid, error_message_with_suggestion)
487
+ """
488
+ if not self.regex_mode_var.get():
489
+ # Text mode patterns are always valid (they get escaped)
490
+ return True, ""
491
+
492
+ find_str = self.find_text_field.get().strip()
493
+ if not find_str:
494
+ return True, ""
495
+
496
+ is_valid, error_msg, suggestion = self._validate_regex(find_str)
497
+ if is_valid:
498
+ return True, ""
499
+ else:
500
+ return False, f"Invalid regex: {error_msg}\n{suggestion}"
418
501
 
419
502
  def preview_find_replace(self):
420
503
  """Highlights matches in input and output without replacing using progressive search."""
@@ -450,6 +533,14 @@ class FindReplaceWidget:
450
533
  self.match_count_label.config(text="Found matches: 0")
451
534
  return
452
535
 
536
+ # Validate regex before proceeding
537
+ is_valid, error_msg = self.validate_current_pattern()
538
+ if not is_valid:
539
+ self._show_warning("Regex Error", error_msg)
540
+ self.match_count_label.config(text="Found matches: Regex Error")
541
+ active_output_tab.text.config(state="disabled")
542
+ return
543
+
453
544
  # Use progressive search if available
454
545
  if PROGRESSIVE_SEARCH_AVAILABLE and self.search_highlighter and self.find_replace_processor:
455
546
  self._preview_with_progressive_search(active_input_tab, active_output_tab, find_str)
@@ -769,13 +860,23 @@ class FindReplaceWidget:
769
860
  find_str = self.find_text_field.get().strip()
770
861
  replace_str = self.replace_text_field.get().strip()
771
862
 
772
- input_text = active_input_tab.text.get("1.0", tk.END)
863
+ input_text = active_input_tab.text.get("1.0", "end-1c")
773
864
 
774
865
  if not find_str:
775
- return input_text.strip()
866
+ return input_text
867
+
868
+ # Validate regex before proceeding
869
+ is_valid, error_msg = self.validate_current_pattern()
870
+ if not is_valid:
871
+ self._show_warning("Regex Error", error_msg)
872
+ return input_text
776
873
 
777
- # Save state for undo
778
- self._save_undo_state(input_text, find_str, replace_str)
874
+ # Save OUTPUT tab state for undo (not input tab - undo restores output)
875
+ if self.output_tabs:
876
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
877
+ output_text = active_output_tab.text.get("1.0", "end-1c")
878
+ self._save_undo_state(output_text, find_str, replace_str)
879
+
779
880
 
780
881
  # Reset replacement count for Replace All
781
882
  self.replaced_count = 0
@@ -899,6 +1000,10 @@ class FindReplaceWidget:
899
1000
  # Focus on output text area
900
1001
  active_output_tab.text.focus_set()
901
1002
 
1003
+ # Save undo state before making changes (use end-1c to avoid trailing newline)
1004
+ current_text = active_output_tab.text.get("1.0", "end-1c")
1005
+ self._save_undo_state(current_text, find_str, replace_str)
1006
+
902
1007
  # Enable editing
903
1008
  active_output_tab.text.config(state="normal")
904
1009
 
@@ -1350,7 +1455,27 @@ class FindReplaceWidget:
1350
1455
  self.settings_manager.save_settings()
1351
1456
 
1352
1457
  def _save_undo_state(self, text: str, find_str: str, replace_str: str):
1353
- """Save current state for undo functionality."""
1458
+ """Save current state for undo functionality using Memento pattern if available."""
1459
+ # Use Memento pattern if available
1460
+ if self.memento_caretaker and MEMENTO_AVAILABLE:
1461
+ try:
1462
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1463
+ before_state = capture_text_state(active_output_tab.text)
1464
+ before_state.content = text # Original text before replacement
1465
+
1466
+ memento = FindReplaceMemento(
1467
+ before_state=before_state,
1468
+ find_pattern=find_str,
1469
+ replace_pattern=replace_str,
1470
+ is_regex=self.regex_mode_var.get() if self.regex_mode_var else False,
1471
+ match_case=self.match_case_var.get() if self.match_case_var else False
1472
+ )
1473
+ self.memento_caretaker.save(memento)
1474
+ return
1475
+ except Exception as e:
1476
+ self.logger.warning(f"Memento save failed, using fallback: {e}")
1477
+
1478
+ # Fallback to legacy undo stack
1354
1479
  undo_entry = {
1355
1480
  'text': text,
1356
1481
  'find': find_str,
@@ -1368,10 +1493,47 @@ class FindReplaceWidget:
1368
1493
  if hasattr(self, 'undo_button'):
1369
1494
  self.undo_button.config(state="normal")
1370
1495
 
1496
+ def _on_undo_redo_change(self, can_undo: bool, can_redo: bool):
1497
+ """Callback when undo/redo availability changes."""
1498
+ try:
1499
+ if hasattr(self, 'undo_button'):
1500
+ self.undo_button.config(state="normal" if can_undo else "disabled")
1501
+ if hasattr(self, 'redo_button'):
1502
+ self.redo_button.config(state="normal" if can_redo else "disabled")
1503
+ except Exception:
1504
+ pass # UI might not be initialized yet
1505
+
1371
1506
  def undo_replace_all(self):
1372
1507
  """Undo the last Replace All operation."""
1508
+ # Try Memento-based undo first
1509
+ if self.memento_caretaker and MEMENTO_AVAILABLE and self.memento_caretaker.can_undo():
1510
+ try:
1511
+ memento = self.memento_caretaker.undo()
1512
+ if memento and memento.before_state:
1513
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1514
+
1515
+ # Capture current state as after_state before restoring
1516
+ memento.after_state = capture_text_state(active_output_tab.text)
1517
+
1518
+ # Restore the before state
1519
+ restore_text_state(active_output_tab.text, memento.before_state)
1520
+
1521
+ # Reset replacement count
1522
+ self.replaced_count = 0
1523
+ self.replaced_count_label.config(text="Replaced matches: 0")
1524
+
1525
+ # Silent undo - just log instead of showing dialog
1526
+ self.logger.info(f"Undone: Replace '{memento.find_pattern[:30]}...' with '{memento.replace_pattern[:30]}...'")
1527
+
1528
+ # Re-apply highlighting to show remaining matches
1529
+ self._refresh_highlighting_after_undo()
1530
+ return
1531
+ except Exception as e:
1532
+ self.logger.error(f"Memento undo failed: {e}")
1533
+
1534
+ # Fallback to legacy undo stack
1373
1535
  if not self.undo_stack:
1374
- self._show_info("Undo", "No Replace All operations to undo.")
1536
+ # Silent - no operations to undo
1375
1537
  return
1376
1538
 
1377
1539
  if not self.output_tabs:
@@ -1396,10 +1558,375 @@ class FindReplaceWidget:
1396
1558
  if not self.undo_stack and hasattr(self, 'undo_button'):
1397
1559
  self.undo_button.config(state="disabled")
1398
1560
 
1399
- self._show_info("Undo", f"Undone: Replace '{undo_entry['find']}' with '{undo_entry['replace']}'")
1561
+ # Silent undo - just log
1562
+ self.logger.info(f"Undone: Replace '{undo_entry['find']}' with '{undo_entry['replace']}'")
1563
+
1564
+ # Re-apply highlighting to show remaining matches
1565
+ self._refresh_highlighting_after_undo()
1400
1566
  except Exception as e:
1401
1567
  self.logger.error(f"Error during undo: {e}")
1402
1568
  self._show_warning("Undo Error", f"Failed to undo: {e}")
1569
+
1570
+ def _refresh_highlighting_after_undo(self):
1571
+ """Refresh match highlighting after an undo operation.
1572
+
1573
+ This highlights remaining matches in the EXISTING output content,
1574
+ without overwriting it with input content (unlike preview_find_replace).
1575
+ """
1576
+ try:
1577
+ find_str = self.find_text_field.get()
1578
+ if not find_str or not self.output_tabs:
1579
+ return
1580
+
1581
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1582
+
1583
+ # Clear existing highlights
1584
+ active_output_tab.text.tag_remove("pink_highlight", "1.0", tk.END)
1585
+ active_output_tab.text.tag_remove("current_match", "1.0", tk.END)
1586
+
1587
+ # Get current output content (already restored by undo)
1588
+ output_text = active_output_tab.text.get("1.0", "end-1c")
1589
+
1590
+ # Find all matches in the output
1591
+ try:
1592
+ if self.regex_mode_var.get():
1593
+ flags = 0 if self.match_case_var.get() else re.IGNORECASE
1594
+ pattern = re.compile(find_str, flags)
1595
+ matches = list(pattern.finditer(output_text))
1596
+ else:
1597
+ # Plain text search
1598
+ matches = []
1599
+ search_text = output_text if self.match_case_var.get() else output_text.lower()
1600
+ search_pattern = find_str if self.match_case_var.get() else find_str.lower()
1601
+ start = 0
1602
+ while True:
1603
+ pos = search_text.find(search_pattern, start)
1604
+ if pos == -1:
1605
+ break
1606
+ # Create a simple match-like object
1607
+ class SimpleMatch:
1608
+ def __init__(self, s, e):
1609
+ self._start = s
1610
+ self._end = e
1611
+ def start(self):
1612
+ return self._start
1613
+ def end(self):
1614
+ return self._end
1615
+ matches.append(SimpleMatch(pos, pos + len(find_str)))
1616
+ start = pos + 1
1617
+
1618
+ # Highlight all matches
1619
+ active_output_tab.text.config(state="normal")
1620
+ for match in matches:
1621
+ start_idx = f"1.0+{match.start()}c"
1622
+ end_idx = f"1.0+{match.end()}c"
1623
+ active_output_tab.text.tag_add("pink_highlight", start_idx, end_idx)
1624
+
1625
+ active_output_tab.text.tag_config("pink_highlight", background="pink")
1626
+ active_output_tab.text.config(state="disabled")
1627
+
1628
+ # Update match count
1629
+ self.match_count_label.config(text=f"Found matches: {len(matches)}")
1630
+
1631
+ except re.error:
1632
+ pass # Invalid regex, skip highlighting
1633
+
1634
+ except Exception as e:
1635
+ self.logger.debug(f"Could not refresh highlighting after undo: {e}")
1636
+
1637
+
1638
+
1639
+ def redo_replace_all(self):
1640
+ """Redo the last undone Replace All operation."""
1641
+ if not self.memento_caretaker or not MEMENTO_AVAILABLE:
1642
+ # Silent - redo not available
1643
+ return
1644
+
1645
+ if not self.memento_caretaker.can_redo():
1646
+ # Silent - no operations to redo
1647
+ return
1648
+
1649
+ if not self.output_tabs:
1650
+ return
1651
+
1652
+ try:
1653
+ memento = self.memento_caretaker.redo()
1654
+ if memento and memento.after_state:
1655
+ active_output_tab = self.output_tabs[self.output_notebook.index(self.output_notebook.select())]
1656
+
1657
+ # Restore the after state (the result of the replacement)
1658
+ restore_text_state(active_output_tab.text, memento.after_state)
1659
+
1660
+ # Silent redo - just log
1661
+ self.logger.info(f"Redone: Replace '{memento.find_pattern[:30]}...' with '{memento.replace_pattern[:30]}...'")
1662
+ else:
1663
+ # Silent - can't redo without after state
1664
+ self.logger.warning("Cannot redo - after state not captured.")
1665
+
1666
+ except Exception as e:
1667
+ self.logger.error(f"Error during redo: {e}")
1668
+ self._show_warning("Redo Error", f"Failed to redo: {e}")
1669
+
1670
+
1671
+ def show_diff_preview(self):
1672
+ """
1673
+ Show a unified diff preview of the find/replace operation.
1674
+ Displays in a popup window before executing Replace All.
1675
+ """
1676
+ if not DIFF_UTILS_AVAILABLE:
1677
+ self._show_warning("Not Available", "Diff preview is not available. Missing diff_utils module.")
1678
+ return
1679
+
1680
+ if not self.input_tabs:
1681
+ return
1682
+
1683
+ # Validate regex first
1684
+ is_valid, error_msg = self.validate_current_pattern()
1685
+ if not is_valid:
1686
+ self._show_warning("Regex Error", error_msg)
1687
+ return
1688
+
1689
+ active_input_tab, _ = self._get_active_tabs()
1690
+ find_str = self.find_text_field.get().strip()
1691
+ replace_str = self.replace_text_field.get()
1692
+
1693
+ if not find_str:
1694
+ self._show_info("No Pattern", "Enter a find pattern to preview changes.")
1695
+ return
1696
+
1697
+ input_text = active_input_tab.text.get("1.0", tk.END)
1698
+ if input_text.endswith('\n'):
1699
+ input_text = input_text[:-1]
1700
+
1701
+ try:
1702
+ # Generate preview using diff_utils
1703
+ preview = generate_find_replace_preview(
1704
+ text=input_text,
1705
+ find_pattern=find_str if self.regex_mode_var.get() else find_str,
1706
+ replace_pattern=replace_str,
1707
+ use_regex=self.regex_mode_var.get(),
1708
+ case_sensitive=self.match_case_var.get(),
1709
+ context_lines=3
1710
+ )
1711
+
1712
+ if preview.match_count == 0:
1713
+ self._show_info("No Matches", "No matches found for the current find pattern.")
1714
+ return
1715
+
1716
+ # Create preview popup window
1717
+ self._show_diff_preview_window(preview, find_str, replace_str)
1718
+
1719
+ except Exception as e:
1720
+ self.logger.error(f"Error generating diff preview: {e}")
1721
+ self._show_warning("Preview Error", f"Failed to generate preview: {e}")
1722
+
1723
+ def _show_diff_preview_window(self, preview: 'FindReplacePreview', find_str: str, replace_str: str):
1724
+ """Display the diff preview in a popup window."""
1725
+ popup = tk.Toplevel(self.parent)
1726
+ popup.title("Find & Replace Preview")
1727
+ popup.geometry("800x500")
1728
+ popup.transient(self.parent)
1729
+
1730
+ # Center the popup
1731
+ popup.update_idletasks()
1732
+ x = (popup.winfo_screenwidth() // 2) - (popup.winfo_width() // 2)
1733
+ y = (popup.winfo_screenheight() // 2) - (popup.winfo_height() // 2)
1734
+ popup.geometry(f"+{x}+{y}")
1735
+
1736
+ # Main frame
1737
+ main_frame = ttk.Frame(popup)
1738
+ main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
1739
+
1740
+ # Summary info
1741
+ summary_frame = ttk.Frame(main_frame)
1742
+ summary_frame.pack(fill=tk.X, pady=(0, 10))
1743
+
1744
+ mode_str = "Regex" if self.regex_mode_var.get() else "Text"
1745
+ case_str = "Case-sensitive" if self.match_case_var.get() else "Case-insensitive"
1746
+
1747
+ ttk.Label(summary_frame, text=f"Find: ", font=("Arial", 10, "bold")).pack(side=tk.LEFT)
1748
+ ttk.Label(summary_frame, text=f"'{find_str}' ", font=("Courier", 10)).pack(side=tk.LEFT)
1749
+ ttk.Label(summary_frame, text=f"→ Replace: ", font=("Arial", 10, "bold")).pack(side=tk.LEFT)
1750
+ ttk.Label(summary_frame, text=f"'{replace_str}'", font=("Courier", 10)).pack(side=tk.LEFT)
1751
+
1752
+ stats_frame = ttk.Frame(main_frame)
1753
+ stats_frame.pack(fill=tk.X, pady=(0, 10))
1754
+ ttk.Label(stats_frame,
1755
+ text=f"Matches: {preview.match_count} | Lines affected: {preview.lines_affected} | Mode: {mode_str} ({case_str})",
1756
+ foreground="gray").pack(side=tk.LEFT)
1757
+
1758
+ # Options frame for checkboxes
1759
+ options_frame = ttk.Frame(main_frame)
1760
+ options_frame.pack(fill=tk.X, pady=(0, 5))
1761
+
1762
+ # Word wrap checkbox
1763
+ wrap_var = tk.BooleanVar(value=True)
1764
+ char_diff_var = tk.BooleanVar(value=False)
1765
+ syntax_var = tk.BooleanVar(value=False)
1766
+
1767
+ def refresh_diff_display():
1768
+ """Refresh the diff display with current options."""
1769
+ diff_text.config(state="normal")
1770
+ diff_text.delete("1.0", tk.END)
1771
+
1772
+ # Apply word wrap setting
1773
+ if wrap_var.get():
1774
+ diff_text.config(wrap=tk.WORD)
1775
+ else:
1776
+ diff_text.config(wrap=tk.NONE)
1777
+
1778
+ # Insert diff with syntax highlighting
1779
+ for line in preview.unified_diff.splitlines(keepends=True):
1780
+ if line.startswith('---') or line.startswith('+++'):
1781
+ diff_text.insert(tk.END, line, "header")
1782
+ elif line.startswith('@@'):
1783
+ diff_text.insert(tk.END, line, "context")
1784
+ elif line.startswith('+'):
1785
+ diff_text.insert(tk.END, line, "addition")
1786
+ elif line.startswith('-'):
1787
+ diff_text.insert(tk.END, line, "deletion")
1788
+ else:
1789
+ diff_text.insert(tk.END, line)
1790
+
1791
+ # Apply char-level highlighting to replacement text in + lines
1792
+ if char_diff_var.get() and replace_str:
1793
+ self._highlight_replacements_in_diff(diff_text, replace_str)
1794
+
1795
+ # Apply syntax highlighting if enabled
1796
+ if syntax_var.get():
1797
+ self._apply_code_syntax_highlighting(diff_text)
1798
+
1799
+ diff_text.config(state="disabled")
1800
+
1801
+
1802
+ ttk.Checkbutton(options_frame, text="Word Wrap", variable=wrap_var,
1803
+ command=refresh_diff_display).pack(side=tk.LEFT, padx=5)
1804
+ ttk.Checkbutton(options_frame, text="Char Diff", variable=char_diff_var,
1805
+ command=refresh_diff_display).pack(side=tk.LEFT, padx=5)
1806
+ ttk.Checkbutton(options_frame, text="Syntax", variable=syntax_var,
1807
+ command=refresh_diff_display).pack(side=tk.LEFT, padx=5)
1808
+
1809
+ # Diff display with scrollbars
1810
+ diff_frame = ttk.Frame(main_frame)
1811
+ diff_frame.pack(fill=tk.BOTH, expand=True)
1812
+
1813
+ # Vertical scrollbar
1814
+ v_scrollbar = ttk.Scrollbar(diff_frame, orient=tk.VERTICAL)
1815
+ v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
1816
+
1817
+ # Horizontal scrollbar
1818
+ h_scrollbar = ttk.Scrollbar(diff_frame, orient=tk.HORIZONTAL)
1819
+ h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)
1820
+
1821
+ diff_text = tk.Text(diff_frame, wrap=tk.WORD, # Default to word wrap ON
1822
+ yscrollcommand=v_scrollbar.set, xscrollcommand=h_scrollbar.set,
1823
+ font=("Courier New", 10), bg="#1e1e1e", fg="#d4d4d4")
1824
+ diff_text.pack(fill=tk.BOTH, expand=True)
1825
+ v_scrollbar.config(command=diff_text.yview)
1826
+ h_scrollbar.config(command=diff_text.xview)
1827
+
1828
+
1829
+ # Configure diff syntax highlighting tags
1830
+ diff_text.tag_configure("header", foreground="#569cd6") # Blue for headers
1831
+ diff_text.tag_configure("addition", foreground="#4ec9b0", background="#1e3a1e") # Green
1832
+ diff_text.tag_configure("deletion", foreground="#ce9178", background="#3a1e1e") # Red
1833
+ diff_text.tag_configure("context", foreground="#808080") # Gray for @@ lines
1834
+ diff_text.tag_configure("char_add", foreground="#ffffff", background="#2d5a2d") # Bright green for char changes
1835
+ diff_text.tag_configure("char_del", foreground="#ffffff", background="#5a2d2d") # Bright red for char changes
1836
+ # Syntax highlighting tags
1837
+ diff_text.tag_configure("keyword", foreground="#c586c0") # Purple for keywords
1838
+ diff_text.tag_configure("string", foreground="#ce9178") # Orange for strings
1839
+ diff_text.tag_configure("comment", foreground="#6a9955") # Green for comments
1840
+ diff_text.tag_configure("number", foreground="#b5cea8") # Light green for numbers
1841
+
1842
+ # Initial render
1843
+ refresh_diff_display()
1844
+
1845
+
1846
+ # Buttons frame
1847
+ button_frame = ttk.Frame(main_frame)
1848
+ button_frame.pack(fill=tk.X, pady=(10, 0))
1849
+
1850
+ def apply_and_close():
1851
+ popup.destroy()
1852
+ self.trigger_replace_all()
1853
+
1854
+ ttk.Button(button_frame, text="Apply Replace All", command=apply_and_close).pack(side=tk.LEFT, padx=5)
1855
+ ttk.Button(button_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT, padx=5)
1856
+
1857
+ def _highlight_replacements_in_diff(self, text_widget, replace_str: str):
1858
+ """
1859
+ Highlight occurrences of the replacement string within + (addition) lines.
1860
+ This shows exactly what was replaced in the diff output.
1861
+ """
1862
+ if not replace_str:
1863
+ return
1864
+
1865
+ # Get all content
1866
+ content = text_widget.get("1.0", tk.END)
1867
+ lines = content.split('\n')
1868
+
1869
+ # Escape the replace string for regex search (treat as literal)
1870
+ escaped_replace = re.escape(replace_str)
1871
+
1872
+ current_line = 1
1873
+ for line in lines:
1874
+ if line.startswith('+') and not line.startswith('+++'):
1875
+ # This is an addition line - find replacement text in it
1876
+ line_content = line[1:] # Skip the + prefix
1877
+ for match in re.finditer(escaped_replace, line_content, re.IGNORECASE if not self.match_case_var.get() else 0):
1878
+ # Calculate positions (add 1 for the + prefix)
1879
+ start_col = match.start() + 1
1880
+ end_col = match.end() + 1
1881
+ start_idx = f"{current_line}.{start_col}"
1882
+ end_idx = f"{current_line}.{end_col}"
1883
+ text_widget.tag_add("char_add", start_idx, end_idx)
1884
+ current_line += 1
1885
+
1886
+
1887
+ def _apply_code_syntax_highlighting(self, text_widget):
1888
+ """
1889
+ Apply basic code syntax highlighting to the diff text.
1890
+ Highlights keywords, strings, comments, and numbers.
1891
+ """
1892
+ content = text_widget.get("1.0", tk.END)
1893
+
1894
+ # Python/JavaScript keywords
1895
+ keywords = r'\b(def|class|return|if|else|elif|for|while|try|except|import|from|as|' \
1896
+ r'function|const|let|var|async|await|true|false|True|False|None|null)\b'
1897
+
1898
+ # Strings (single and double quoted)
1899
+ strings = r'(["\'])(?:(?!\1|\\).|\\.)*\1'
1900
+
1901
+ # Comments
1902
+ comments = r'(#.*$|//.*$|/\*.*?\*/)'
1903
+
1904
+ # Numbers
1905
+ numbers = r'\b\d+\.?\d*\b'
1906
+
1907
+ import re
1908
+
1909
+ patterns = [
1910
+ (keywords, "keyword"),
1911
+ (strings, "string"),
1912
+ (comments, "comment"),
1913
+ (numbers, "number"),
1914
+ ]
1915
+
1916
+ for pattern, tag in patterns:
1917
+ for match in re.finditer(pattern, content, re.MULTILINE):
1918
+ start = f"1.0+{match.start()}c"
1919
+ end = f"1.0+{match.end()}c"
1920
+ # Only apply if not already in a header/context line
1921
+ try:
1922
+ line_start = text_widget.index(f"{start} linestart")
1923
+ first_char = text_widget.get(line_start, f"{line_start}+1c")
1924
+ if first_char not in ('@', '-', '+'):
1925
+ continue # Only highlight actual diff content
1926
+ text_widget.tag_add(tag, start, end)
1927
+ except tk.TclError:
1928
+ pass
1929
+
1403
1930
 
1404
1931
  def on_regex_mode_change(self):
1405
1932
  """Handle changes to regex mode checkbox."""
@@ -1559,7 +2086,11 @@ class FindReplaceWidget:
1559
2086
  new_item_id = len(pattern_library) - 1
1560
2087
  tree.selection_set(str(new_item_id))
1561
2088
  tree.focus(str(new_item_id))
1562
- self.settings_manager.save_settings()
2089
+ # Explicitly update and save pattern library
2090
+ if hasattr(self.settings_manager, 'set_pattern_library'):
2091
+ self.settings_manager.set_pattern_library(pattern_library)
2092
+ else:
2093
+ self.settings_manager.save_settings()
1563
2094
 
1564
2095
  def delete_pattern():
1565
2096
  selection = tree.selection()
@@ -1567,7 +2098,11 @@ class FindReplaceWidget:
1567
2098
  item_id = int(selection[0])
1568
2099
  del pattern_library[item_id]
1569
2100
  refresh_tree()
1570
- self.settings_manager.save_settings()
2101
+ # Explicitly update and save pattern library
2102
+ if hasattr(self.settings_manager, 'set_pattern_library'):
2103
+ self.settings_manager.set_pattern_library(pattern_library)
2104
+ else:
2105
+ self.settings_manager.save_settings()
1571
2106
 
1572
2107
  def move_up():
1573
2108
  selection = tree.selection()
@@ -1580,7 +2115,7 @@ class FindReplaceWidget:
1580
2115
  refresh_tree()
1581
2116
  tree.selection_set(str(item_id-1))
1582
2117
  tree.focus(str(item_id-1))
1583
- self.settings_manager.save_settings()
2118
+ self.settings_manager.set_pattern_library(pattern_library) if hasattr(self.settings_manager, 'set_pattern_library') else self.settings_manager.save_settings()
1584
2119
 
1585
2120
  def move_down():
1586
2121
  selection = tree.selection()
@@ -1593,7 +2128,7 @@ class FindReplaceWidget:
1593
2128
  refresh_tree()
1594
2129
  tree.selection_set(str(item_id+1))
1595
2130
  tree.focus(str(item_id+1))
1596
- self.settings_manager.save_settings()
2131
+ self.settings_manager.set_pattern_library(pattern_library) if hasattr(self.settings_manager, 'set_pattern_library') else self.settings_manager.save_settings()
1597
2132
 
1598
2133
  ttk.Button(left_buttons, text="Add", command=add_pattern).pack(side=tk.LEFT, padx=(0,5))
1599
2134
  ttk.Button(left_buttons, text="Delete", command=delete_pattern).pack(side=tk.LEFT, padx=5)
@@ -1706,7 +2241,11 @@ Replace: {pattern['replace']}"""
1706
2241
  pattern[field_name] = new_value
1707
2242
  tree.set(item, column, new_value)
1708
2243
  entry.destroy()
1709
- self.settings_manager.save_settings()
2244
+ # Explicitly update and save pattern library
2245
+ if hasattr(self.settings_manager, 'set_pattern_library'):
2246
+ self.settings_manager.set_pattern_library(pattern_library)
2247
+ else:
2248
+ self.settings_manager.save_settings()
1710
2249
 
1711
2250
  def cancel_edit():
1712
2251
  entry.destroy()