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.
- package/core/backup_recovery_manager.py +169 -3
- package/core/data_directory.py +549 -0
- package/core/diff_utils.py +239 -0
- package/core/efficient_line_numbers.py +30 -0
- package/core/mcp/find_replace_diff.py +334 -0
- package/core/mcp/tool_registry.py +369 -9
- package/core/memento.py +275 -0
- package/mcp.json +1 -1
- package/migrate_data.py +127 -0
- package/package.json +5 -2
- package/pomera.py +408 -10
- package/pomera_mcp_server.py +2 -2
- package/requirements.txt +1 -0
- package/scripts/Dockerfile.alpine +43 -0
- package/scripts/Dockerfile.gui-test +54 -0
- package/scripts/Dockerfile.linux +43 -0
- package/scripts/Dockerfile.test-linux +80 -0
- package/scripts/Dockerfile.ubuntu +39 -0
- package/scripts/README.md +53 -0
- package/scripts/build-all.bat +113 -0
- package/scripts/build-docker.bat +53 -0
- package/scripts/build-docker.sh +55 -0
- package/scripts/build-optimized.bat +101 -0
- package/scripts/build.sh +78 -0
- package/scripts/docker-compose.test.yml +27 -0
- package/scripts/docker-compose.yml +32 -0
- package/scripts/postinstall.js +62 -0
- package/scripts/requirements-minimal.txt +33 -0
- package/scripts/test-linux-simple.bat +28 -0
- package/scripts/validate-release-workflow.py +450 -0
- package/tools/diff_viewer.py +797 -52
- package/tools/find_replace.py +551 -12
- package/tools/notes_widget.py +48 -8
- package/tools/regex_extractor.py +5 -5
package/tools/find_replace.py
CHANGED
|
@@ -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",
|
|
863
|
+
input_text = active_input_tab.text.get("1.0", "end-1c")
|
|
773
864
|
|
|
774
865
|
if not find_str:
|
|
775
|
-
return input_text
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|