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/diff_viewer.py
CHANGED
|
@@ -16,7 +16,8 @@ Author: Promera AI Commander
|
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
18
|
import tkinter as tk
|
|
19
|
-
from tkinter import ttk
|
|
19
|
+
from tkinter import ttk, filedialog, messagebox
|
|
20
|
+
import difflib
|
|
20
21
|
import re
|
|
21
22
|
import platform
|
|
22
23
|
import logging
|
|
@@ -45,7 +46,7 @@ class TextWithLineNumbers(tk.Frame):
|
|
|
45
46
|
def __init__(self, *args, **kwargs):
|
|
46
47
|
super().__init__(*args, **kwargs)
|
|
47
48
|
self.text = tk.Text(self, wrap=tk.WORD, height=15, width=50, undo=True)
|
|
48
|
-
self.linenumbers = tk.Canvas(self, width=
|
|
49
|
+
self.linenumbers = tk.Canvas(self, width=50, bg='#f0f0f0', highlightthickness=0)
|
|
49
50
|
|
|
50
51
|
self.linenumbers.pack(side=tk.LEFT, fill=tk.Y)
|
|
51
52
|
self.text.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
|
|
@@ -100,7 +101,10 @@ class DiffViewerWidget:
|
|
|
100
101
|
|
|
101
102
|
# Settings for diff comparison
|
|
102
103
|
self.settings = {
|
|
103
|
-
"option": "ignore_case" # Default comparison mode
|
|
104
|
+
"option": "ignore_case", # Default comparison mode
|
|
105
|
+
"char_level_diff": False, # Character-level diff mode (vs word-level)
|
|
106
|
+
"detect_moved": False, # Detect moved lines
|
|
107
|
+
"syntax_highlight": False # Syntax highlighting for code
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
# Create the main frame
|
|
@@ -113,6 +117,22 @@ class DiffViewerWidget:
|
|
|
113
117
|
self.input_stats_bar = None
|
|
114
118
|
self.output_stats_bar = None
|
|
115
119
|
|
|
120
|
+
# Diff tracking for navigation and summary
|
|
121
|
+
self.diff_positions = [] # List of (line_number, diff_type) tuples
|
|
122
|
+
self.current_diff_index = -1
|
|
123
|
+
self.diff_counts = {"additions": 0, "deletions": 0, "modifications": 0}
|
|
124
|
+
self.similarity_score = 0.0
|
|
125
|
+
self.diff_summary_bar = None
|
|
126
|
+
|
|
127
|
+
# Regex filter mode flags
|
|
128
|
+
self.input_regex_mode = tk.BooleanVar(value=False)
|
|
129
|
+
self.output_regex_mode = tk.BooleanVar(value=False)
|
|
130
|
+
|
|
131
|
+
# Store original source text for comparison (to avoid accumulating blank lines)
|
|
132
|
+
self.comparison_source_input = {} # {tab_idx: original_text}
|
|
133
|
+
self.comparison_source_output = {} # {tab_idx: original_text}
|
|
134
|
+
self.has_run_comparison = {} # {tab_idx: bool}
|
|
135
|
+
|
|
116
136
|
# Initialize UI components
|
|
117
137
|
self._create_ui()
|
|
118
138
|
self._setup_event_bindings()
|
|
@@ -126,7 +146,7 @@ class DiffViewerWidget:
|
|
|
126
146
|
from tkinter import messagebox
|
|
127
147
|
messagebox.showerror(title, message, parent=self.parent)
|
|
128
148
|
return True
|
|
129
|
-
except:
|
|
149
|
+
except Exception:
|
|
130
150
|
return False
|
|
131
151
|
|
|
132
152
|
def _show_warning(self, title, message, category="warning"):
|
|
@@ -138,7 +158,7 @@ class DiffViewerWidget:
|
|
|
138
158
|
import tkinter.messagebox as messagebox
|
|
139
159
|
messagebox.showwarning(title, message, parent=self.parent)
|
|
140
160
|
return True
|
|
141
|
-
except:
|
|
161
|
+
except Exception:
|
|
142
162
|
return False
|
|
143
163
|
|
|
144
164
|
def _create_ui(self):
|
|
@@ -147,6 +167,7 @@ class DiffViewerWidget:
|
|
|
147
167
|
self._create_notebooks()
|
|
148
168
|
self._create_tabs()
|
|
149
169
|
self._create_statistics_bars()
|
|
170
|
+
self._create_diff_summary_bar()
|
|
150
171
|
self._configure_text_tags()
|
|
151
172
|
|
|
152
173
|
def _create_title_rows(self):
|
|
@@ -173,8 +194,10 @@ class DiffViewerWidget:
|
|
|
173
194
|
ttk.Label(input_filter_frame, text="Filter:").pack(side=tk.LEFT)
|
|
174
195
|
self.input_filter_var = tk.StringVar()
|
|
175
196
|
self.input_filter_entry = ttk.Entry(input_filter_frame, textvariable=self.input_filter_var, width=25)
|
|
176
|
-
self.input_filter_entry.pack(side=tk.LEFT, padx=(5,
|
|
197
|
+
self.input_filter_entry.pack(side=tk.LEFT, padx=(5, 2), fill=tk.X, expand=True)
|
|
177
198
|
self.input_filter_var.trace_add("write", self._on_input_filter_changed)
|
|
199
|
+
ttk.Checkbutton(input_filter_frame, text="Rx", variable=self.input_regex_mode,
|
|
200
|
+
command=self._on_input_filter_changed, width=3).pack(side=tk.LEFT, padx=(0, 2))
|
|
178
201
|
ttk.Button(input_filter_frame, text="✕", command=self._clear_input_filter, width=3).pack(side=tk.LEFT)
|
|
179
202
|
|
|
180
203
|
# Output title row
|
|
@@ -209,8 +232,10 @@ class DiffViewerWidget:
|
|
|
209
232
|
ttk.Label(output_filter_frame, text="Filter:").pack(side=tk.LEFT)
|
|
210
233
|
self.output_filter_var = tk.StringVar()
|
|
211
234
|
self.output_filter_entry = ttk.Entry(output_filter_frame, textvariable=self.output_filter_var, width=25)
|
|
212
|
-
self.output_filter_entry.pack(side=tk.LEFT, padx=(5,
|
|
235
|
+
self.output_filter_entry.pack(side=tk.LEFT, padx=(5, 2), fill=tk.X, expand=True)
|
|
213
236
|
self.output_filter_var.trace_add("write", self._on_output_filter_changed)
|
|
237
|
+
ttk.Checkbutton(output_filter_frame, text="Rx", variable=self.output_regex_mode,
|
|
238
|
+
command=self._on_output_filter_changed, width=3).pack(side=tk.LEFT, padx=(0, 2))
|
|
214
239
|
ttk.Button(output_filter_frame, text="✕", command=self._clear_output_filter, width=3).pack(side=tk.LEFT)
|
|
215
240
|
|
|
216
241
|
# Store original content for filtering
|
|
@@ -246,6 +271,37 @@ class DiffViewerWidget:
|
|
|
246
271
|
padding=(5, 2)
|
|
247
272
|
)
|
|
248
273
|
self.output_stats_bar.grid(row=2, column=1, sticky="ew", padx=(5, 0), pady=(5, 0))
|
|
274
|
+
|
|
275
|
+
def _create_diff_summary_bar(self):
|
|
276
|
+
"""Create diff summary bar with navigation buttons and similarity score."""
|
|
277
|
+
# Container frame spanning both columns
|
|
278
|
+
summary_frame = ttk.Frame(self.diff_frame)
|
|
279
|
+
summary_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(5, 0))
|
|
280
|
+
|
|
281
|
+
# Navigation buttons on the left
|
|
282
|
+
nav_frame = ttk.Frame(summary_frame)
|
|
283
|
+
nav_frame.pack(side=tk.LEFT)
|
|
284
|
+
|
|
285
|
+
self.prev_diff_btn = ttk.Button(nav_frame, text="⬆ Prev", command=self._goto_prev_diff, width=8)
|
|
286
|
+
self.prev_diff_btn.pack(side=tk.LEFT, padx=(0, 5))
|
|
287
|
+
self.prev_diff_btn.state(['disabled'])
|
|
288
|
+
|
|
289
|
+
self.next_diff_btn = ttk.Button(nav_frame, text="⬇ Next", command=self._goto_next_diff, width=8)
|
|
290
|
+
self.next_diff_btn.pack(side=tk.LEFT, padx=(0, 10))
|
|
291
|
+
self.next_diff_btn.state(['disabled'])
|
|
292
|
+
|
|
293
|
+
# Diff summary label in the center
|
|
294
|
+
self.diff_summary_bar = ttk.Label(
|
|
295
|
+
summary_frame,
|
|
296
|
+
text="Run comparison to see diff summary",
|
|
297
|
+
anchor=tk.CENTER,
|
|
298
|
+
padding=(5, 2)
|
|
299
|
+
)
|
|
300
|
+
self.diff_summary_bar.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
301
|
+
|
|
302
|
+
# Export button on the right
|
|
303
|
+
self.export_html_btn = ttk.Button(summary_frame, text="Export HTML", command=self._export_to_html, width=13)
|
|
304
|
+
self.export_html_btn.pack(side=tk.RIGHT, padx=(10, 0))
|
|
249
305
|
|
|
250
306
|
def _create_tabs(self):
|
|
251
307
|
"""Create the text tabs for input and output."""
|
|
@@ -282,16 +338,27 @@ class DiffViewerWidget:
|
|
|
282
338
|
self.output_notebook.add(output_tab, text=f"{i+1}:")
|
|
283
339
|
|
|
284
340
|
def _configure_text_tags(self):
|
|
285
|
-
"""Configure text tags for highlighting differences."""
|
|
341
|
+
"""Configure text tags for highlighting differences and syntax."""
|
|
286
342
|
for tab_list in [self.input_tabs, self.output_tabs]:
|
|
287
343
|
for tab in tab_list:
|
|
288
344
|
widget = tab.text
|
|
289
345
|
widget.config(state="normal")
|
|
346
|
+
# Diff highlighting tags
|
|
290
347
|
widget.tag_configure("addition", background="#e6ffed")
|
|
291
348
|
widget.tag_configure("deletion", background="#ffebe9")
|
|
292
349
|
widget.tag_configure("modification", background="#e6f7ff")
|
|
293
350
|
widget.tag_configure("inline_add", background="#a7f0ba")
|
|
294
351
|
widget.tag_configure("inline_del", background="#ffc9c9")
|
|
352
|
+
widget.tag_configure("moved", background="#f3e8ff") # Lavender for moved lines
|
|
353
|
+
|
|
354
|
+
# Syntax highlighting tags (lower priority than diff tags)
|
|
355
|
+
widget.tag_configure("syntax_keyword", foreground="#0000ff") # Blue
|
|
356
|
+
widget.tag_configure("syntax_string", foreground="#008000") # Green
|
|
357
|
+
widget.tag_configure("syntax_comment", foreground="#808080", font=("", 0, "italic")) # Gray italic
|
|
358
|
+
widget.tag_configure("syntax_number", foreground="#ff8c00") # Dark orange
|
|
359
|
+
widget.tag_configure("syntax_function", foreground="#800080") # Purple
|
|
360
|
+
widget.tag_configure("syntax_decorator", foreground="#b8860b") # Dark goldenrod
|
|
361
|
+
widget.tag_configure("syntax_class", foreground="#2e8b57") # Sea green
|
|
295
362
|
|
|
296
363
|
def _setup_event_bindings(self):
|
|
297
364
|
"""Set up event bindings for synchronized scrolling."""
|
|
@@ -415,6 +482,11 @@ class DiffViewerWidget:
|
|
|
415
482
|
self.output_tabs[i].text.delete("1.0", tk.END)
|
|
416
483
|
self.output_tabs[i].text.insert("1.0", output_tabs_content[i])
|
|
417
484
|
|
|
485
|
+
# Clear stored comparison source text (so new content is compared fresh)
|
|
486
|
+
self.comparison_source_input.clear()
|
|
487
|
+
self.comparison_source_output.clear()
|
|
488
|
+
self.has_run_comparison.clear()
|
|
489
|
+
|
|
418
490
|
# Update tab labels after loading content
|
|
419
491
|
self.update_tab_labels()
|
|
420
492
|
|
|
@@ -456,11 +528,15 @@ class DiffViewerWidget:
|
|
|
456
528
|
|
|
457
529
|
Args:
|
|
458
530
|
text: Input text to preprocess
|
|
459
|
-
option: Comparison option ('ignore_case', 'match_case', 'ignore_whitespace')
|
|
531
|
+
option: Comparison option ('ignore_case', 'match_case', 'ignore_whitespace', 'sentence_level')
|
|
460
532
|
|
|
461
533
|
Returns:
|
|
462
534
|
List of dicts with 'raw' and 'cmp' keys
|
|
463
535
|
"""
|
|
536
|
+
# For sentence-level comparison, split by sentences instead of lines
|
|
537
|
+
if option == "sentence_level":
|
|
538
|
+
return self._preprocess_sentences(text)
|
|
539
|
+
|
|
464
540
|
lines = text.splitlines()
|
|
465
541
|
processed = []
|
|
466
542
|
for line in lines:
|
|
@@ -469,8 +545,116 @@ class DiffViewerWidget:
|
|
|
469
545
|
cmp_line = cmp_line.lower()
|
|
470
546
|
elif option == "ignore_whitespace":
|
|
471
547
|
cmp_line = re.sub(r"\s+", " ", cmp_line).strip()
|
|
548
|
+
elif option == "ignore_punctuation":
|
|
549
|
+
# Remove all punctuation for comparison (useful for prose)
|
|
550
|
+
cmp_line = re.sub(r'[^\w\s]', '', cmp_line.lower()).strip()
|
|
551
|
+
cmp_line = re.sub(r"\s+", " ", cmp_line) # Normalize whitespace too
|
|
472
552
|
processed.append({"raw": line, "cmp": cmp_line})
|
|
473
553
|
return processed
|
|
554
|
+
|
|
555
|
+
def _preprocess_sentences(self, text):
|
|
556
|
+
"""
|
|
557
|
+
Split text into sentences for sentence-level comparison.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
text: Input text to split into sentences
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
List of dicts with 'raw' (original sentence) and 'cmp' (normalized) keys
|
|
564
|
+
"""
|
|
565
|
+
# Replace line breaks with spaces for continuous text
|
|
566
|
+
continuous_text = re.sub(r'\s+', ' ', text).strip()
|
|
567
|
+
|
|
568
|
+
if not continuous_text:
|
|
569
|
+
return []
|
|
570
|
+
|
|
571
|
+
# Split into sentences using pattern that handles common cases
|
|
572
|
+
# Matches: . ! ? followed by space or end of string
|
|
573
|
+
# But not: abbreviations like Mr. Mrs. Dr. etc.
|
|
574
|
+
sentence_pattern = r'(?<![A-Z][a-z])(?<![A-Z])(?<=\.|\!|\?)\s+'
|
|
575
|
+
sentences = re.split(sentence_pattern, continuous_text)
|
|
576
|
+
|
|
577
|
+
processed = []
|
|
578
|
+
for sentence in sentences:
|
|
579
|
+
sentence = sentence.strip()
|
|
580
|
+
if sentence:
|
|
581
|
+
# Normalize for comparison: lowercase, normalize whitespace
|
|
582
|
+
cmp_sentence = re.sub(r'\s+', ' ', sentence.lower()).strip()
|
|
583
|
+
processed.append({"raw": sentence, "cmp": cmp_sentence})
|
|
584
|
+
|
|
585
|
+
return processed
|
|
586
|
+
|
|
587
|
+
def _clean_alignment_blanks(self, text):
|
|
588
|
+
"""
|
|
589
|
+
Remove consecutive empty lines (alignment artifacts) while preserving single empty lines.
|
|
590
|
+
|
|
591
|
+
This prevents blank lines from accumulating across comparison sessions.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
text: Input text that may contain alignment blank lines
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
Cleaned text with consecutive empty lines collapsed
|
|
598
|
+
"""
|
|
599
|
+
if not text:
|
|
600
|
+
return text
|
|
601
|
+
|
|
602
|
+
lines = text.splitlines()
|
|
603
|
+
cleaned_lines = []
|
|
604
|
+
prev_was_empty = False
|
|
605
|
+
|
|
606
|
+
for line in lines:
|
|
607
|
+
is_empty = line.strip() == ""
|
|
608
|
+
|
|
609
|
+
if is_empty and prev_was_empty:
|
|
610
|
+
# Skip consecutive empty lines
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
cleaned_lines.append(line)
|
|
614
|
+
prev_was_empty = is_empty
|
|
615
|
+
|
|
616
|
+
return '\n'.join(cleaned_lines)
|
|
617
|
+
|
|
618
|
+
def reset_comparison_source(self):
|
|
619
|
+
"""
|
|
620
|
+
Reset comparison source cache and clean widget content of accumulated blanks.
|
|
621
|
+
|
|
622
|
+
Called when user clicks 'Compare Active Tabs' to ensure fresh content is used.
|
|
623
|
+
"""
|
|
624
|
+
# Clear the source cache
|
|
625
|
+
self.comparison_source_input.clear()
|
|
626
|
+
self.comparison_source_output.clear()
|
|
627
|
+
self.has_run_comparison.clear()
|
|
628
|
+
|
|
629
|
+
# Clean the active tab widgets of accumulated blank lines
|
|
630
|
+
try:
|
|
631
|
+
active_input_idx = self.input_notebook.index("current")
|
|
632
|
+
active_output_idx = self.output_notebook.index("current")
|
|
633
|
+
|
|
634
|
+
input_widget = self.input_tabs[active_input_idx].text
|
|
635
|
+
output_widget = self.output_tabs[active_output_idx].text
|
|
636
|
+
|
|
637
|
+
# Get and clean input content
|
|
638
|
+
input_text = input_widget.get("1.0", tk.END)
|
|
639
|
+
if input_text.endswith('\n'):
|
|
640
|
+
input_text = input_text[:-1]
|
|
641
|
+
cleaned_input = self._clean_alignment_blanks(input_text)
|
|
642
|
+
|
|
643
|
+
# Get and clean output content
|
|
644
|
+
output_text = output_widget.get("1.0", tk.END)
|
|
645
|
+
if output_text.endswith('\n'):
|
|
646
|
+
output_text = output_text[:-1]
|
|
647
|
+
cleaned_output = self._clean_alignment_blanks(output_text)
|
|
648
|
+
|
|
649
|
+
# Replace widget content with cleaned version
|
|
650
|
+
input_widget.delete("1.0", tk.END)
|
|
651
|
+
input_widget.insert("1.0", cleaned_input)
|
|
652
|
+
|
|
653
|
+
output_widget.delete("1.0", tk.END)
|
|
654
|
+
output_widget.insert("1.0", cleaned_output)
|
|
655
|
+
|
|
656
|
+
except (tk.TclError, IndexError) as e:
|
|
657
|
+
self.logger.warning(f"Could not clean widget content: {e}")
|
|
474
658
|
|
|
475
659
|
def run_comparison(self, option=None):
|
|
476
660
|
"""
|
|
@@ -494,14 +678,28 @@ class DiffViewerWidget:
|
|
|
494
678
|
input_widget = self.input_tabs[active_input_idx].text
|
|
495
679
|
output_widget = self.output_tabs[active_output_idx].text
|
|
496
680
|
|
|
497
|
-
#
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
input_text = input_text[:-1]
|
|
681
|
+
# Check if we have stored original text from a previous comparison
|
|
682
|
+
# This prevents accumulating blank lines when switching comparison modes
|
|
683
|
+
tab_key = (active_input_idx, active_output_idx)
|
|
501
684
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
685
|
+
if tab_key in self.has_run_comparison and self.has_run_comparison[tab_key]:
|
|
686
|
+
# Use stored original text (without alignment blanks)
|
|
687
|
+
input_text = self.comparison_source_input.get(active_input_idx, "")
|
|
688
|
+
output_text = self.comparison_source_output.get(active_output_idx, "")
|
|
689
|
+
else:
|
|
690
|
+
# First comparison - read current widget content and store it
|
|
691
|
+
input_text = input_widget.get("1.0", tk.END)
|
|
692
|
+
if input_text.endswith('\n'):
|
|
693
|
+
input_text = input_text[:-1]
|
|
694
|
+
|
|
695
|
+
output_text = output_widget.get("1.0", tk.END)
|
|
696
|
+
if output_text.endswith('\n'):
|
|
697
|
+
output_text = output_text[:-1]
|
|
698
|
+
|
|
699
|
+
# Store the original source text (no blank line cleaning - preserves structure)
|
|
700
|
+
self.comparison_source_input[active_input_idx] = input_text
|
|
701
|
+
self.comparison_source_output[active_output_idx] = output_text
|
|
702
|
+
self.has_run_comparison[tab_key] = True
|
|
505
703
|
|
|
506
704
|
except (tk.TclError, IndexError):
|
|
507
705
|
self.logger.error("Could not get active tabs for comparison")
|
|
@@ -522,19 +720,32 @@ class DiffViewerWidget:
|
|
|
522
720
|
# Clear existing content
|
|
523
721
|
input_widget.delete("1.0", tk.END)
|
|
524
722
|
output_widget.delete("1.0", tk.END)
|
|
723
|
+
|
|
724
|
+
# Reset diff tracking
|
|
725
|
+
self.diff_positions = []
|
|
726
|
+
self.current_diff_index = -1
|
|
727
|
+
self.diff_counts = {"additions": 0, "deletions": 0, "modifications": 0}
|
|
728
|
+
self.similarity_score = 0.0
|
|
525
729
|
|
|
526
730
|
# Handle empty texts
|
|
527
|
-
if not input_text.strip() and not output_text.strip():
|
|
731
|
+
if not input_text.strip() and not output_text.strip():
|
|
732
|
+
self._update_diff_summary()
|
|
528
733
|
return
|
|
529
734
|
elif not input_text.strip():
|
|
530
|
-
for line in output_text.splitlines():
|
|
735
|
+
for i, line in enumerate(output_text.splitlines()):
|
|
531
736
|
input_widget.insert(tk.END, '\n')
|
|
532
737
|
output_widget.insert(tk.END, line + '\n', 'addition')
|
|
738
|
+
self.diff_positions.append((i + 1, 'addition'))
|
|
739
|
+
self.diff_counts["additions"] += 1
|
|
740
|
+
self._update_diff_summary()
|
|
533
741
|
return
|
|
534
742
|
elif not output_text.strip():
|
|
535
|
-
for line in input_text.splitlines():
|
|
743
|
+
for i, line in enumerate(input_text.splitlines()):
|
|
536
744
|
input_widget.insert(tk.END, line + '\n', 'deletion')
|
|
537
745
|
output_widget.insert(tk.END, '\n')
|
|
746
|
+
self.diff_positions.append((i + 1, 'deletion'))
|
|
747
|
+
self.diff_counts["deletions"] += 1
|
|
748
|
+
self._update_diff_summary()
|
|
538
749
|
return
|
|
539
750
|
|
|
540
751
|
# Preprocess texts for comparison
|
|
@@ -544,9 +755,11 @@ class DiffViewerWidget:
|
|
|
544
755
|
right_cmp = [r["cmp"] for r in right_lines]
|
|
545
756
|
|
|
546
757
|
try:
|
|
547
|
-
import difflib
|
|
548
758
|
matcher = difflib.SequenceMatcher(None, left_cmp, right_cmp, autojunk=False)
|
|
549
759
|
|
|
760
|
+
# Compute similarity score
|
|
761
|
+
self.similarity_score = matcher.ratio() * 100
|
|
762
|
+
|
|
550
763
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
551
764
|
if tag == 'equal':
|
|
552
765
|
for i in range(i1, i2):
|
|
@@ -557,11 +770,23 @@ class DiffViewerWidget:
|
|
|
557
770
|
for i in range(i1, i2):
|
|
558
771
|
input_widget.insert(tk.END, left_lines[i]["raw"] + '\n', 'deletion')
|
|
559
772
|
output_widget.insert(tk.END, '\n')
|
|
773
|
+
# Get line number AFTER inserting (current line minus 1 since cursor is on next line)
|
|
774
|
+
actual_line = int(input_widget.index(tk.INSERT).split('.')[0]) - 1
|
|
775
|
+
if actual_line < 1:
|
|
776
|
+
actual_line = 1
|
|
777
|
+
self.diff_positions.append((actual_line, 'deletion'))
|
|
778
|
+
self.diff_counts["deletions"] += 1
|
|
560
779
|
|
|
561
780
|
elif tag == 'insert':
|
|
562
781
|
for j in range(j1, j2):
|
|
563
782
|
input_widget.insert(tk.END, '\n')
|
|
564
783
|
output_widget.insert(tk.END, right_lines[j]["raw"] + '\n', 'addition')
|
|
784
|
+
# Get line number AFTER inserting
|
|
785
|
+
actual_line = int(input_widget.index(tk.INSERT).split('.')[0]) - 1
|
|
786
|
+
if actual_line < 1:
|
|
787
|
+
actual_line = 1
|
|
788
|
+
self.diff_positions.append((actual_line, 'addition'))
|
|
789
|
+
self.diff_counts["additions"] += 1
|
|
565
790
|
|
|
566
791
|
elif tag == 'replace':
|
|
567
792
|
input_block = [l["raw"] for l in left_lines[i1:i2]]
|
|
@@ -576,12 +801,28 @@ class DiffViewerWidget:
|
|
|
576
801
|
for line1, line2 in zip(input_block, output_block):
|
|
577
802
|
if line1 and line2:
|
|
578
803
|
self._highlight_word_diffs(input_widget, [line1], output_widget, [line2])
|
|
804
|
+
# Get line number AFTER inserting
|
|
805
|
+
actual_line = int(input_widget.index(tk.INSERT).split('.')[0]) - 1
|
|
806
|
+
if actual_line < 1:
|
|
807
|
+
actual_line = 1
|
|
808
|
+
self.diff_positions.append((actual_line, 'modification'))
|
|
809
|
+
self.diff_counts["modifications"] += 1
|
|
579
810
|
elif line1:
|
|
580
811
|
input_widget.insert(tk.END, line1 + '\n', 'deletion')
|
|
581
812
|
output_widget.insert(tk.END, '\n')
|
|
813
|
+
actual_line = int(input_widget.index(tk.INSERT).split('.')[0]) - 1
|
|
814
|
+
if actual_line < 1:
|
|
815
|
+
actual_line = 1
|
|
816
|
+
self.diff_positions.append((actual_line, 'deletion'))
|
|
817
|
+
self.diff_counts["deletions"] += 1
|
|
582
818
|
elif line2:
|
|
583
819
|
input_widget.insert(tk.END, '\n')
|
|
584
820
|
output_widget.insert(tk.END, line2 + '\n', 'addition')
|
|
821
|
+
actual_line = int(input_widget.index(tk.INSERT).split('.')[0]) - 1
|
|
822
|
+
if actual_line < 1:
|
|
823
|
+
actual_line = 1
|
|
824
|
+
self.diff_positions.append((actual_line, 'addition'))
|
|
825
|
+
self.diff_counts["additions"] += 1
|
|
585
826
|
|
|
586
827
|
except Exception as e:
|
|
587
828
|
self.logger.error(f"Error in diff computation: {e}")
|
|
@@ -593,8 +834,130 @@ class DiffViewerWidget:
|
|
|
593
834
|
output_widget.yview_moveto(0)
|
|
594
835
|
self._setup_sync()
|
|
595
836
|
|
|
837
|
+
# Detect moved lines if enabled
|
|
838
|
+
if self.settings.get("detect_moved", False):
|
|
839
|
+
self._detect_moved_lines(input_widget, output_widget)
|
|
840
|
+
|
|
841
|
+
# Apply syntax highlighting if enabled
|
|
842
|
+
if self.settings.get("syntax_highlight", False):
|
|
843
|
+
self._apply_syntax_highlighting(input_widget)
|
|
844
|
+
self._apply_syntax_highlighting(output_widget)
|
|
845
|
+
|
|
596
846
|
# Update tab labels after comparison
|
|
597
847
|
self.update_tab_labels()
|
|
848
|
+
|
|
849
|
+
# Update diff summary bar
|
|
850
|
+
self._update_diff_summary()
|
|
851
|
+
|
|
852
|
+
def _detect_moved_lines(self, input_widget, output_widget):
|
|
853
|
+
"""
|
|
854
|
+
Detect lines that were moved (appear in both delete and insert sections).
|
|
855
|
+
Re-tags them as 'moved' instead of deletion/addition.
|
|
856
|
+
"""
|
|
857
|
+
try:
|
|
858
|
+
# Get all lines with deletion tag from input
|
|
859
|
+
deleted_lines = {}
|
|
860
|
+
for tag_range in input_widget.tag_ranges("deletion"):
|
|
861
|
+
if isinstance(tag_range, str):
|
|
862
|
+
continue
|
|
863
|
+
# Get line number from index
|
|
864
|
+
line_num = int(str(tag_range).split('.')[0])
|
|
865
|
+
line_content = input_widget.get(f"{line_num}.0", f"{line_num}.end").strip()
|
|
866
|
+
if line_content:
|
|
867
|
+
deleted_lines[line_content.lower()] = line_num
|
|
868
|
+
|
|
869
|
+
# Get all lines with addition tag from output
|
|
870
|
+
added_lines = {}
|
|
871
|
+
for tag_range in output_widget.tag_ranges("addition"):
|
|
872
|
+
if isinstance(tag_range, str):
|
|
873
|
+
continue
|
|
874
|
+
line_num = int(str(tag_range).split('.')[0])
|
|
875
|
+
line_content = output_widget.get(f"{line_num}.0", f"{line_num}.end").strip()
|
|
876
|
+
if line_content:
|
|
877
|
+
added_lines[line_content.lower()] = line_num
|
|
878
|
+
|
|
879
|
+
# Find lines that appear in both (moved lines)
|
|
880
|
+
moved_count = 0
|
|
881
|
+
for content, input_line in deleted_lines.items():
|
|
882
|
+
if content in added_lines:
|
|
883
|
+
output_line = added_lines[content]
|
|
884
|
+
# Re-tag as moved in both widgets
|
|
885
|
+
input_widget.tag_remove("deletion", f"{input_line}.0", f"{input_line}.end+1c")
|
|
886
|
+
input_widget.tag_add("moved", f"{input_line}.0", f"{input_line}.end+1c")
|
|
887
|
+
|
|
888
|
+
output_widget.tag_remove("addition", f"{output_line}.0", f"{output_line}.end+1c")
|
|
889
|
+
output_widget.tag_add("moved", f"{output_line}.0", f"{output_line}.end+1c")
|
|
890
|
+
|
|
891
|
+
moved_count += 1
|
|
892
|
+
|
|
893
|
+
# Update counts
|
|
894
|
+
if moved_count > 0:
|
|
895
|
+
self.diff_counts["moved"] = moved_count
|
|
896
|
+
# Reduce deletion/addition counts
|
|
897
|
+
self.diff_counts["deletions"] = max(0, self.diff_counts.get("deletions", 0) - moved_count)
|
|
898
|
+
self.diff_counts["additions"] = max(0, self.diff_counts.get("additions", 0) - moved_count)
|
|
899
|
+
|
|
900
|
+
except Exception as e:
|
|
901
|
+
self.logger.error(f"Error detecting moved lines: {e}")
|
|
902
|
+
|
|
903
|
+
def _apply_syntax_highlighting(self, widget):
|
|
904
|
+
"""
|
|
905
|
+
Apply syntax highlighting to code content in a text widget.
|
|
906
|
+
Uses regex patterns for common programming constructs.
|
|
907
|
+
"""
|
|
908
|
+
try:
|
|
909
|
+
content = widget.get("1.0", tk.END)
|
|
910
|
+
|
|
911
|
+
# Define patterns for syntax highlighting
|
|
912
|
+
patterns = [
|
|
913
|
+
# Python/JS keywords
|
|
914
|
+
(r'\b(def|class|import|from|return|if|elif|else|for|while|try|except|finally|with|as|'
|
|
915
|
+
r'raise|yield|lambda|and|or|not|in|is|True|False|None|async|await|'
|
|
916
|
+
r'function|const|let|var|new|this|typeof|instanceof|export|default)\b',
|
|
917
|
+
'syntax_keyword'),
|
|
918
|
+
|
|
919
|
+
# Triple-quoted strings (must come before single/double quotes)
|
|
920
|
+
(r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'', 'syntax_string'),
|
|
921
|
+
|
|
922
|
+
# Single and double quoted strings
|
|
923
|
+
(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'', 'syntax_string'),
|
|
924
|
+
|
|
925
|
+
# Comments (Python # and JS //)
|
|
926
|
+
(r'#[^\n]*|//[^\n]*', 'syntax_comment'),
|
|
927
|
+
|
|
928
|
+
# Numbers (integers, floats, hex)
|
|
929
|
+
(r'\b(?:0x[0-9a-fA-F]+|0b[01]+|0o[0-7]+|\d+\.?\d*(?:e[+-]?\d+)?)\b', 'syntax_number'),
|
|
930
|
+
|
|
931
|
+
# Function definitions
|
|
932
|
+
(r'\b(?:def|function)\s+([a-zA-Z_][a-zA-Z0-9_]*)', 'syntax_function'),
|
|
933
|
+
|
|
934
|
+
# Decorators
|
|
935
|
+
(r'@[a-zA-Z_][a-zA-Z0-9_.]*', 'syntax_decorator'),
|
|
936
|
+
|
|
937
|
+
# Class definitions
|
|
938
|
+
(r'\bclass\s+([a-zA-Z_][a-zA-Z0-9_]*)', 'syntax_class'),
|
|
939
|
+
]
|
|
940
|
+
|
|
941
|
+
for pattern, tag in patterns:
|
|
942
|
+
for match in re.finditer(pattern, content):
|
|
943
|
+
start_idx = f"1.0+{match.start()}c"
|
|
944
|
+
end_idx = f"1.0+{match.end()}c"
|
|
945
|
+
|
|
946
|
+
# For function/class definitions, highlight just the name (group 1)
|
|
947
|
+
if tag in ('syntax_function', 'syntax_class') and match.lastindex:
|
|
948
|
+
name_start = match.start(1)
|
|
949
|
+
name_end = match.end(1)
|
|
950
|
+
start_idx = f"1.0+{name_start}c"
|
|
951
|
+
end_idx = f"1.0+{name_end}c"
|
|
952
|
+
|
|
953
|
+
widget.tag_add(tag, start_idx, end_idx)
|
|
954
|
+
|
|
955
|
+
# Ensure diff tags have higher priority (raise them above syntax tags)
|
|
956
|
+
for diff_tag in ['addition', 'deletion', 'modification', 'inline_add', 'inline_del', 'moved']:
|
|
957
|
+
widget.tag_raise(diff_tag)
|
|
958
|
+
|
|
959
|
+
except Exception as e:
|
|
960
|
+
self.logger.error(f"Error applying syntax highlighting: {e}")
|
|
598
961
|
|
|
599
962
|
def _highlight_word_diffs(self, w1, lines1, w2, lines2):
|
|
600
963
|
"""
|
|
@@ -606,6 +969,9 @@ class DiffViewerWidget:
|
|
|
606
969
|
w2: Second text widget
|
|
607
970
|
lines2: Lines for second widget
|
|
608
971
|
"""
|
|
972
|
+
# Check if character-level diff is enabled
|
|
973
|
+
use_char_diff = self.settings.get("char_level_diff", False)
|
|
974
|
+
|
|
609
975
|
for line1, line2 in zip(lines1, lines2):
|
|
610
976
|
w1.insert(tk.END, line1 + '\n', 'modification')
|
|
611
977
|
w2.insert(tk.END, line2 + '\n', 'modification')
|
|
@@ -613,24 +979,49 @@ class DiffViewerWidget:
|
|
|
613
979
|
line_start1 = w1.index(f"{w1.index(tk.INSERT)} -1 lines linestart")
|
|
614
980
|
line_start2 = w2.index(f"{w2.index(tk.INSERT)} -1 lines linestart")
|
|
615
981
|
|
|
982
|
+
if use_char_diff:
|
|
983
|
+
# Character-level diff
|
|
984
|
+
self._apply_char_diff(w1, line1, line_start1, w2, line2, line_start2)
|
|
985
|
+
else:
|
|
986
|
+
# Word-level diff
|
|
987
|
+
self._apply_word_diff(w1, line1, line_start1, w2, line2, line_start2)
|
|
988
|
+
|
|
989
|
+
def _apply_word_diff(self, w1, line1, line_start1, w2, line2, line_start2):
|
|
990
|
+
"""Apply word-level diff highlighting."""
|
|
991
|
+
try:
|
|
616
992
|
words1 = re.split(r'(\s+)', line1)
|
|
617
993
|
words2 = re.split(r'(\s+)', line2)
|
|
618
994
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
995
|
+
matcher = difflib.SequenceMatcher(None, words1, words2)
|
|
996
|
+
|
|
997
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
998
|
+
if tag == 'delete' or tag == 'replace':
|
|
999
|
+
start_char1 = len("".join(words1[:i1]))
|
|
1000
|
+
end_char1 = len("".join(words1[:i2]))
|
|
1001
|
+
w1.tag_add('inline_del', f"{line_start1}+{start_char1}c", f"{line_start1}+{end_char1}c")
|
|
1002
|
+
if tag == 'insert' or tag == 'replace':
|
|
1003
|
+
start_char2 = len("".join(words2[:j1]))
|
|
1004
|
+
end_char2 = len("".join(words2[:j2]))
|
|
1005
|
+
w2.tag_add('inline_add', f"{line_start2}+{start_char2}c", f"{line_start2}+{end_char2}c")
|
|
1006
|
+
except Exception as e:
|
|
1007
|
+
self.logger.error(f"Error in word-level diff highlighting: {e}")
|
|
1008
|
+
|
|
1009
|
+
def _apply_char_diff(self, w1, line1, line_start1, w2, line2, line_start2):
|
|
1010
|
+
"""Apply character-level diff highlighting."""
|
|
1011
|
+
try:
|
|
1012
|
+
# Split into individual characters
|
|
1013
|
+
chars1 = list(line1)
|
|
1014
|
+
chars2 = list(line2)
|
|
1015
|
+
|
|
1016
|
+
matcher = difflib.SequenceMatcher(None, chars1, chars2)
|
|
622
1017
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
end_char2 = len("".join(words2[:j2]))
|
|
631
|
-
w2.tag_add('inline_add', f"{line_start2}+{start_char2}c", f"{line_start2}+{end_char2}c")
|
|
632
|
-
except Exception as e:
|
|
633
|
-
self.logger.error(f"Error in word-level diff highlighting: {e}")
|
|
1018
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
1019
|
+
if tag == 'delete' or tag == 'replace':
|
|
1020
|
+
w1.tag_add('inline_del', f"{line_start1}+{i1}c", f"{line_start1}+{i2}c")
|
|
1021
|
+
if tag == 'insert' or tag == 'replace':
|
|
1022
|
+
w2.tag_add('inline_add', f"{line_start2}+{j1}c", f"{line_start2}+{j2}c")
|
|
1023
|
+
except Exception as e:
|
|
1024
|
+
self.logger.error(f"Error in character-level diff highlighting: {e}")
|
|
634
1025
|
|
|
635
1026
|
def clear_all_input_tabs(self):
|
|
636
1027
|
"""Clear all input tabs."""
|
|
@@ -676,7 +1067,6 @@ class DiffViewerWidget:
|
|
|
676
1067
|
def load_file_to_input(self):
|
|
677
1068
|
"""Load file content to the active input tab."""
|
|
678
1069
|
try:
|
|
679
|
-
from tkinter import filedialog
|
|
680
1070
|
file_path = filedialog.askopenfilename(
|
|
681
1071
|
title="Select file to load",
|
|
682
1072
|
filetypes=[
|
|
@@ -735,6 +1125,196 @@ class DiffViewerWidget:
|
|
|
735
1125
|
|
|
736
1126
|
except Exception as e:
|
|
737
1127
|
self.logger.error(f"Error updating tab labels: {e}")
|
|
1128
|
+
|
|
1129
|
+
def _update_diff_summary(self):
|
|
1130
|
+
"""Update the diff summary bar with current comparison results."""
|
|
1131
|
+
if not self.diff_summary_bar:
|
|
1132
|
+
return
|
|
1133
|
+
|
|
1134
|
+
total_diffs = len(self.diff_positions)
|
|
1135
|
+
adds = self.diff_counts.get("additions", 0)
|
|
1136
|
+
dels = self.diff_counts.get("deletions", 0)
|
|
1137
|
+
mods = self.diff_counts.get("modifications", 0)
|
|
1138
|
+
moved = self.diff_counts.get("moved", 0)
|
|
1139
|
+
|
|
1140
|
+
if total_diffs == 0 and moved == 0:
|
|
1141
|
+
summary_text = "No differences found | 100% similar"
|
|
1142
|
+
self.prev_diff_btn.state(['disabled'])
|
|
1143
|
+
self.next_diff_btn.state(['disabled'])
|
|
1144
|
+
else:
|
|
1145
|
+
parts = [f"+{adds} additions", f"-{dels} deletions", f"~{mods} modifications"]
|
|
1146
|
+
if moved > 0:
|
|
1147
|
+
parts.append(f"↔{moved} moved")
|
|
1148
|
+
parts.append(f"{self.similarity_score:.1f}% similar")
|
|
1149
|
+
summary_text = " | ".join(parts)
|
|
1150
|
+
self.prev_diff_btn.state(['!disabled'])
|
|
1151
|
+
self.next_diff_btn.state(['!disabled'])
|
|
1152
|
+
|
|
1153
|
+
self.diff_summary_bar.config(text=summary_text)
|
|
1154
|
+
|
|
1155
|
+
def _goto_prev_diff(self):
|
|
1156
|
+
"""Navigate to the previous difference."""
|
|
1157
|
+
if not self.diff_positions:
|
|
1158
|
+
return
|
|
1159
|
+
|
|
1160
|
+
if self.current_diff_index <= 0:
|
|
1161
|
+
self.current_diff_index = len(self.diff_positions) - 1
|
|
1162
|
+
else:
|
|
1163
|
+
self.current_diff_index -= 1
|
|
1164
|
+
|
|
1165
|
+
self._scroll_to_diff(self.current_diff_index)
|
|
1166
|
+
|
|
1167
|
+
def _goto_next_diff(self):
|
|
1168
|
+
"""Navigate to the next difference."""
|
|
1169
|
+
if not self.diff_positions:
|
|
1170
|
+
return
|
|
1171
|
+
|
|
1172
|
+
if self.current_diff_index >= len(self.diff_positions) - 1:
|
|
1173
|
+
self.current_diff_index = 0
|
|
1174
|
+
else:
|
|
1175
|
+
self.current_diff_index += 1
|
|
1176
|
+
|
|
1177
|
+
self._scroll_to_diff(self.current_diff_index)
|
|
1178
|
+
|
|
1179
|
+
def _scroll_to_diff(self, diff_index):
|
|
1180
|
+
"""Scroll both text widgets to show the specified difference."""
|
|
1181
|
+
try:
|
|
1182
|
+
if diff_index < 0 or diff_index >= len(self.diff_positions):
|
|
1183
|
+
return
|
|
1184
|
+
|
|
1185
|
+
line_num, diff_type = self.diff_positions[diff_index]
|
|
1186
|
+
|
|
1187
|
+
active_input_tab = self.input_tabs[self.input_notebook.index("current")]
|
|
1188
|
+
active_output_tab = self.output_tabs[self.output_notebook.index("current")]
|
|
1189
|
+
|
|
1190
|
+
input_widget = active_input_tab.text
|
|
1191
|
+
output_widget = active_output_tab.text
|
|
1192
|
+
|
|
1193
|
+
# Line index for the diff
|
|
1194
|
+
line_index = f"{line_num}.0"
|
|
1195
|
+
line_end = f"{line_num}.end"
|
|
1196
|
+
|
|
1197
|
+
# Remove any previous navigation highlight
|
|
1198
|
+
input_widget.tag_remove("nav_highlight", "1.0", tk.END)
|
|
1199
|
+
output_widget.tag_remove("nav_highlight", "1.0", tk.END)
|
|
1200
|
+
|
|
1201
|
+
# Configure navigation highlight tag (bright yellow background with high priority)
|
|
1202
|
+
input_widget.tag_configure("nav_highlight", background="#ffff00", foreground="#000000")
|
|
1203
|
+
output_widget.tag_configure("nav_highlight", background="#ffff00", foreground="#000000")
|
|
1204
|
+
|
|
1205
|
+
# Add highlight to current diff line
|
|
1206
|
+
input_widget.tag_add("nav_highlight", line_index, line_end)
|
|
1207
|
+
output_widget.tag_add("nav_highlight", line_index, line_end)
|
|
1208
|
+
|
|
1209
|
+
# Raise nav_highlight above all other tags so it's visible
|
|
1210
|
+
input_widget.tag_raise("nav_highlight")
|
|
1211
|
+
output_widget.tag_raise("nav_highlight")
|
|
1212
|
+
|
|
1213
|
+
# Move cursor to the beginning of the line
|
|
1214
|
+
input_widget.mark_set(tk.INSERT, line_index)
|
|
1215
|
+
output_widget.mark_set(tk.INSERT, line_index)
|
|
1216
|
+
|
|
1217
|
+
# Focus the input widget
|
|
1218
|
+
input_widget.focus_set()
|
|
1219
|
+
|
|
1220
|
+
# Scroll to make the line visible (centered if possible)
|
|
1221
|
+
input_widget.see(line_index)
|
|
1222
|
+
output_widget.see(line_index)
|
|
1223
|
+
|
|
1224
|
+
# Force update of line numbers if available
|
|
1225
|
+
if hasattr(active_input_tab, '_on_text_modified'):
|
|
1226
|
+
active_input_tab._on_text_modified()
|
|
1227
|
+
if hasattr(active_output_tab, '_on_text_modified'):
|
|
1228
|
+
active_output_tab._on_text_modified()
|
|
1229
|
+
|
|
1230
|
+
# Update summary to show current position
|
|
1231
|
+
total = len(self.diff_positions)
|
|
1232
|
+
current = diff_index + 1
|
|
1233
|
+
base_summary = self.diff_summary_bar.cget("text").split(" | Diff ")[0]
|
|
1234
|
+
self.diff_summary_bar.config(text=f"{base_summary} | Diff {current}/{total}")
|
|
1235
|
+
|
|
1236
|
+
except (tk.TclError, IndexError) as e:
|
|
1237
|
+
self.logger.error(f"Error scrolling to diff: {e}")
|
|
1238
|
+
|
|
1239
|
+
def _export_to_html(self):
|
|
1240
|
+
"""Export the current diff comparison to an HTML file."""
|
|
1241
|
+
try:
|
|
1242
|
+
active_input_idx = self.input_notebook.index("current")
|
|
1243
|
+
active_output_idx = self.output_notebook.index("current")
|
|
1244
|
+
|
|
1245
|
+
input_text = self.input_tabs[active_input_idx].text.get("1.0", tk.END)
|
|
1246
|
+
output_text = self.output_tabs[active_output_idx].text.get("1.0", tk.END)
|
|
1247
|
+
|
|
1248
|
+
# Remove trailing newlines
|
|
1249
|
+
if input_text.endswith('\n'):
|
|
1250
|
+
input_text = input_text[:-1]
|
|
1251
|
+
if output_text.endswith('\n'):
|
|
1252
|
+
output_text = output_text[:-1]
|
|
1253
|
+
|
|
1254
|
+
if not input_text.strip() and not output_text.strip():
|
|
1255
|
+
messagebox.showinfo("Export", "No content to export.", parent=self.parent)
|
|
1256
|
+
return
|
|
1257
|
+
|
|
1258
|
+
# Ask for save location
|
|
1259
|
+
file_path = filedialog.asksaveasfilename(
|
|
1260
|
+
title="Export Diff as HTML",
|
|
1261
|
+
defaultextension=".html",
|
|
1262
|
+
filetypes=[("HTML files", "*.html"), ("All files", "*.*")],
|
|
1263
|
+
parent=self.parent
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
if not file_path:
|
|
1267
|
+
return
|
|
1268
|
+
|
|
1269
|
+
# Generate HTML diff
|
|
1270
|
+
html_diff = difflib.HtmlDiff(wrapcolumn=80)
|
|
1271
|
+
html_content = html_diff.make_file(
|
|
1272
|
+
input_text.splitlines(),
|
|
1273
|
+
output_text.splitlines(),
|
|
1274
|
+
fromdesc="Input",
|
|
1275
|
+
todesc="Output",
|
|
1276
|
+
context=False
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1279
|
+
# Add custom styling for better appearance
|
|
1280
|
+
custom_css = """
|
|
1281
|
+
<style>
|
|
1282
|
+
body { font-family: 'Segoe UI', Tahoma, sans-serif; margin: 20px; }
|
|
1283
|
+
table.diff { border-collapse: collapse; width: 100%; }
|
|
1284
|
+
.diff_header { background-color: #f0f0f0; }
|
|
1285
|
+
.diff_next { background-color: #e0e0e0; }
|
|
1286
|
+
td { padding: 2px 8px; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; }
|
|
1287
|
+
.diff_add { background-color: #e6ffed; }
|
|
1288
|
+
.diff_chg { background-color: #e6f7ff; }
|
|
1289
|
+
.diff_sub { background-color: #ffebe9; }
|
|
1290
|
+
.summary { margin-bottom: 15px; padding: 10px; background: #f5f5f5; border-radius: 5px; }
|
|
1291
|
+
</style>
|
|
1292
|
+
"""
|
|
1293
|
+
|
|
1294
|
+
# Insert summary and custom CSS
|
|
1295
|
+
summary_html = f"""
|
|
1296
|
+
<div class="summary">
|
|
1297
|
+
<strong>Diff Summary:</strong>
|
|
1298
|
+
+{self.diff_counts.get('additions', 0)} additions |
|
|
1299
|
+
-{self.diff_counts.get('deletions', 0)} deletions |
|
|
1300
|
+
~{self.diff_counts.get('modifications', 0)} modifications |
|
|
1301
|
+
{self.similarity_score:.1f}% similar
|
|
1302
|
+
</div>
|
|
1303
|
+
"""
|
|
1304
|
+
|
|
1305
|
+
html_content = html_content.replace("</head>", f"{custom_css}</head>")
|
|
1306
|
+
html_content = html_content.replace("<body>", f"<body>{summary_html}")
|
|
1307
|
+
|
|
1308
|
+
# Write to file
|
|
1309
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
1310
|
+
f.write(html_content)
|
|
1311
|
+
|
|
1312
|
+
self.logger.info(f"Exported diff to: {file_path}")
|
|
1313
|
+
messagebox.showinfo("Export Complete", f"Diff exported to:\n{file_path}", parent=self.parent)
|
|
1314
|
+
|
|
1315
|
+
except Exception as e:
|
|
1316
|
+
self.logger.error(f"Error exporting to HTML: {e}")
|
|
1317
|
+
self._show_error("Export Error", f"Could not export diff: {str(e)}")
|
|
738
1318
|
|
|
739
1319
|
def _on_input_filter_changed(self, *args):
|
|
740
1320
|
"""Handle input filter text changes."""
|
|
@@ -768,7 +1348,20 @@ class DiffViewerWidget:
|
|
|
768
1348
|
if filter_text:
|
|
769
1349
|
# Apply filter
|
|
770
1350
|
lines = original_content.split('\n')
|
|
771
|
-
|
|
1351
|
+
|
|
1352
|
+
if self.input_regex_mode.get():
|
|
1353
|
+
# Regex mode
|
|
1354
|
+
try:
|
|
1355
|
+
pattern = re.compile(filter_text, re.IGNORECASE)
|
|
1356
|
+
filtered_lines = [line for line in lines if pattern.search(line)]
|
|
1357
|
+
except re.error as e:
|
|
1358
|
+
self.logger.warning(f"Invalid regex pattern: {e}")
|
|
1359
|
+
# Fallback to literal search on regex error
|
|
1360
|
+
filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
|
|
1361
|
+
else:
|
|
1362
|
+
# Simple substring match
|
|
1363
|
+
filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
|
|
1364
|
+
|
|
772
1365
|
filtered_content = '\n'.join(filtered_lines)
|
|
773
1366
|
|
|
774
1367
|
current_tab.text.delete("1.0", tk.END)
|
|
@@ -803,7 +1396,20 @@ class DiffViewerWidget:
|
|
|
803
1396
|
if filter_text:
|
|
804
1397
|
# Apply filter
|
|
805
1398
|
lines = original_content.split('\n')
|
|
806
|
-
|
|
1399
|
+
|
|
1400
|
+
if self.output_regex_mode.get():
|
|
1401
|
+
# Regex mode
|
|
1402
|
+
try:
|
|
1403
|
+
pattern = re.compile(filter_text, re.IGNORECASE)
|
|
1404
|
+
filtered_lines = [line for line in lines if pattern.search(line)]
|
|
1405
|
+
except re.error as e:
|
|
1406
|
+
self.logger.warning(f"Invalid regex pattern: {e}")
|
|
1407
|
+
# Fallback to literal search on regex error
|
|
1408
|
+
filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
|
|
1409
|
+
else:
|
|
1410
|
+
# Simple substring match
|
|
1411
|
+
filtered_lines = [line for line in lines if filter_text.lower() in line.lower()]
|
|
1412
|
+
|
|
807
1413
|
filtered_content = '\n'.join(filtered_lines)
|
|
808
1414
|
|
|
809
1415
|
current_tab.text.delete("1.0", tk.END)
|
|
@@ -909,8 +1515,11 @@ class DiffViewerWidget:
|
|
|
909
1515
|
words = [word for word in stripped_text.split() if word]
|
|
910
1516
|
word_count = len(words)
|
|
911
1517
|
|
|
912
|
-
# Count sentences
|
|
913
|
-
|
|
1518
|
+
# Count sentences using regex pattern that handles abbreviations better
|
|
1519
|
+
# Looks for sentence-ending punctuation followed by space or end of string
|
|
1520
|
+
sentence_pattern = r'[.!?]+(?:\s|$)'
|
|
1521
|
+
sentence_matches = re.findall(sentence_pattern, text)
|
|
1522
|
+
sentence_count = len(sentence_matches)
|
|
914
1523
|
if sentence_count == 0 and char_count > 0:
|
|
915
1524
|
sentence_count = 1
|
|
916
1525
|
|
|
@@ -951,17 +1560,29 @@ class DiffViewerSettingsWidget:
|
|
|
951
1560
|
# Get current settings
|
|
952
1561
|
settings = diff_viewer.get_settings()
|
|
953
1562
|
default_option = settings.get("option", "ignore_case")
|
|
1563
|
+
default_char_level = settings.get("char_level_diff", False)
|
|
1564
|
+
default_detect_moved = settings.get("detect_moved", False)
|
|
1565
|
+
default_syntax = settings.get("syntax_highlight", False)
|
|
954
1566
|
|
|
955
|
-
# Create option
|
|
1567
|
+
# Create option variables
|
|
956
1568
|
self.option_var = tk.StringVar(value=default_option)
|
|
1569
|
+
self.char_level_var = tk.BooleanVar(value=default_char_level)
|
|
1570
|
+
self.detect_moved_var = tk.BooleanVar(value=default_detect_moved)
|
|
1571
|
+
self.syntax_var = tk.BooleanVar(value=default_syntax)
|
|
957
1572
|
|
|
958
1573
|
# Create UI
|
|
959
1574
|
self._create_ui()
|
|
960
1575
|
|
|
961
1576
|
def _create_ui(self):
|
|
962
|
-
"""Create the settings UI."""
|
|
1577
|
+
"""Create the settings UI in two rows for better layout."""
|
|
1578
|
+
# Row 1: Comparison mode radio buttons
|
|
1579
|
+
row1 = ttk.Frame(self.parent)
|
|
1580
|
+
row1.pack(fill=tk.X, pady=(0, 3))
|
|
1581
|
+
|
|
1582
|
+
ttk.Label(row1, text="Mode:").pack(side=tk.LEFT, padx=(0, 5))
|
|
1583
|
+
|
|
963
1584
|
ttk.Radiobutton(
|
|
964
|
-
|
|
1585
|
+
row1,
|
|
965
1586
|
text="Ignore case",
|
|
966
1587
|
variable=self.option_var,
|
|
967
1588
|
value="ignore_case",
|
|
@@ -969,7 +1590,7 @@ class DiffViewerSettingsWidget:
|
|
|
969
1590
|
).pack(side=tk.LEFT, padx=(0, 8))
|
|
970
1591
|
|
|
971
1592
|
ttk.Radiobutton(
|
|
972
|
-
|
|
1593
|
+
row1,
|
|
973
1594
|
text="Match case",
|
|
974
1595
|
variable=self.option_var,
|
|
975
1596
|
value="match_case",
|
|
@@ -977,24 +1598,71 @@ class DiffViewerSettingsWidget:
|
|
|
977
1598
|
).pack(side=tk.LEFT, padx=(0, 8))
|
|
978
1599
|
|
|
979
1600
|
ttk.Radiobutton(
|
|
980
|
-
|
|
1601
|
+
row1,
|
|
981
1602
|
text="Ignore whitespace",
|
|
982
1603
|
variable=self.option_var,
|
|
983
1604
|
value="ignore_whitespace",
|
|
984
1605
|
command=self._on_option_change
|
|
985
|
-
).pack(side=tk.LEFT, padx=(0,
|
|
1606
|
+
).pack(side=tk.LEFT, padx=(0, 8))
|
|
1607
|
+
|
|
1608
|
+
ttk.Radiobutton(
|
|
1609
|
+
row1,
|
|
1610
|
+
text="Ignore punctuation",
|
|
1611
|
+
variable=self.option_var,
|
|
1612
|
+
value="ignore_punctuation",
|
|
1613
|
+
command=self._on_option_change
|
|
1614
|
+
).pack(side=tk.LEFT, padx=(0, 8))
|
|
1615
|
+
|
|
1616
|
+
ttk.Radiobutton(
|
|
1617
|
+
row1,
|
|
1618
|
+
text="Sentences",
|
|
1619
|
+
variable=self.option_var,
|
|
1620
|
+
value="sentence_level",
|
|
1621
|
+
command=self._on_option_change
|
|
1622
|
+
).pack(side=tk.LEFT, padx=(0, 8))
|
|
1623
|
+
|
|
1624
|
+
# Row 2: Action buttons and options
|
|
1625
|
+
row2 = ttk.Frame(self.parent)
|
|
1626
|
+
row2.pack(fill=tk.X)
|
|
986
1627
|
|
|
987
1628
|
ttk.Button(
|
|
988
|
-
|
|
1629
|
+
row2,
|
|
989
1630
|
text="Compare Active Tabs",
|
|
990
1631
|
command=self._run_comparison
|
|
991
|
-
).pack(side=tk.LEFT, padx=
|
|
1632
|
+
).pack(side=tk.LEFT, padx=(0, 10))
|
|
1633
|
+
|
|
1634
|
+
ttk.Label(row2, text="|").pack(side=tk.LEFT, padx=5)
|
|
992
1635
|
|
|
993
|
-
|
|
994
|
-
|
|
1636
|
+
ttk.Label(row2, text="Options:").pack(side=tk.LEFT, padx=(0, 5))
|
|
1637
|
+
|
|
1638
|
+
# Character-level diff checkbox
|
|
1639
|
+
ttk.Checkbutton(
|
|
1640
|
+
row2,
|
|
1641
|
+
text="Char diff",
|
|
1642
|
+
variable=self.char_level_var,
|
|
1643
|
+
command=self._on_char_level_change
|
|
1644
|
+
).pack(side=tk.LEFT, padx=(0, 8))
|
|
1645
|
+
|
|
1646
|
+
# Detect moved lines checkbox
|
|
1647
|
+
ttk.Checkbutton(
|
|
1648
|
+
row2,
|
|
1649
|
+
text="Detect moved",
|
|
1650
|
+
variable=self.detect_moved_var,
|
|
1651
|
+
command=self._on_detect_moved_change
|
|
1652
|
+
).pack(side=tk.LEFT, padx=(0, 8))
|
|
1653
|
+
|
|
1654
|
+
# Syntax highlighting checkbox
|
|
1655
|
+
ttk.Checkbutton(
|
|
1656
|
+
row2,
|
|
1657
|
+
text="Syntax",
|
|
1658
|
+
variable=self.syntax_var,
|
|
1659
|
+
command=self._on_syntax_change
|
|
1660
|
+
).pack(side=tk.LEFT, padx=(0, 8))
|
|
1661
|
+
|
|
1662
|
+
ttk.Label(row2, text="|").pack(side=tk.LEFT, padx=5)
|
|
995
1663
|
|
|
996
1664
|
ttk.Button(
|
|
997
|
-
|
|
1665
|
+
row2,
|
|
998
1666
|
text="List Comparator",
|
|
999
1667
|
command=self._launch_list_comparator
|
|
1000
1668
|
).pack(side=tk.LEFT, padx=5)
|
|
@@ -1002,14 +1670,75 @@ class DiffViewerSettingsWidget:
|
|
|
1002
1670
|
def _on_option_change(self):
|
|
1003
1671
|
"""Handle option change."""
|
|
1004
1672
|
option = self.option_var.get()
|
|
1673
|
+
|
|
1674
|
+
# Show confirmation dialog for sentence mode
|
|
1675
|
+
if option == "sentence_level":
|
|
1676
|
+
if not self._confirm_sentence_mode():
|
|
1677
|
+
# User cancelled - revert to previous option
|
|
1678
|
+
previous_option = self.diff_viewer.settings.get("option", "ignore_case")
|
|
1679
|
+
self.option_var.set(previous_option)
|
|
1680
|
+
return
|
|
1681
|
+
|
|
1005
1682
|
self.diff_viewer.update_settings({"option": option})
|
|
1006
1683
|
|
|
1007
1684
|
if self.on_setting_change:
|
|
1008
1685
|
self.on_setting_change("Diff Viewer", {"option": option})
|
|
1009
1686
|
|
|
1687
|
+
def _confirm_sentence_mode(self):
|
|
1688
|
+
"""Show confirmation dialog for sentence mode."""
|
|
1689
|
+
message = (
|
|
1690
|
+
"Sentence Mode restructures text for comparison.\n\n"
|
|
1691
|
+
"• Text will be split into sentences (not lines)\n"
|
|
1692
|
+
"• Each sentence appears on its own line\n"
|
|
1693
|
+
"• Original line breaks will not be preserved\n\n"
|
|
1694
|
+
"This is useful for comparing prose where sentences\n"
|
|
1695
|
+
"may span multiple lines or be wrapped differently.\n\n"
|
|
1696
|
+
"Continue with Sentence Mode?"
|
|
1697
|
+
)
|
|
1698
|
+
return messagebox.askyesno(
|
|
1699
|
+
"Sentence Mode",
|
|
1700
|
+
message,
|
|
1701
|
+
icon=messagebox.WARNING
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
def _on_char_level_change(self):
|
|
1705
|
+
"""Handle character-level diff toggle."""
|
|
1706
|
+
char_level = self.char_level_var.get()
|
|
1707
|
+
self.diff_viewer.update_settings({"char_level_diff": char_level})
|
|
1708
|
+
|
|
1709
|
+
# Re-run comparison with new setting
|
|
1710
|
+
self._run_comparison()
|
|
1711
|
+
|
|
1712
|
+
if self.on_setting_change:
|
|
1713
|
+
self.on_setting_change("Diff Viewer", {"char_level_diff": char_level})
|
|
1714
|
+
|
|
1715
|
+
def _on_detect_moved_change(self):
|
|
1716
|
+
"""Handle detect moved lines toggle."""
|
|
1717
|
+
detect_moved = self.detect_moved_var.get()
|
|
1718
|
+
self.diff_viewer.update_settings({"detect_moved": detect_moved})
|
|
1719
|
+
|
|
1720
|
+
# Re-run comparison with new setting
|
|
1721
|
+
self._run_comparison()
|
|
1722
|
+
|
|
1723
|
+
if self.on_setting_change:
|
|
1724
|
+
self.on_setting_change("Diff Viewer", {"detect_moved": detect_moved})
|
|
1725
|
+
|
|
1726
|
+
def _on_syntax_change(self):
|
|
1727
|
+
"""Handle syntax highlighting toggle."""
|
|
1728
|
+
syntax = self.syntax_var.get()
|
|
1729
|
+
self.diff_viewer.update_settings({"syntax_highlight": syntax})
|
|
1730
|
+
|
|
1731
|
+
# Re-run comparison with new setting
|
|
1732
|
+
self._run_comparison()
|
|
1733
|
+
|
|
1734
|
+
if self.on_setting_change:
|
|
1735
|
+
self.on_setting_change("Diff Viewer", {"syntax_highlight": syntax})
|
|
1736
|
+
|
|
1010
1737
|
def _run_comparison(self):
|
|
1011
1738
|
"""Run the diff comparison."""
|
|
1012
1739
|
option = self.option_var.get()
|
|
1740
|
+
# Reset comparison source and clean widget content of accumulated blanks
|
|
1741
|
+
self.diff_viewer.reset_comparison_source()
|
|
1013
1742
|
self.diff_viewer.run_comparison(option)
|
|
1014
1743
|
|
|
1015
1744
|
def _launch_list_comparator(self):
|
|
@@ -1048,7 +1777,7 @@ class DiffViewerSettingsWidget:
|
|
|
1048
1777
|
startupinfo.wShowWindow = subprocess.SW_HIDE
|
|
1049
1778
|
subprocess.Popen([sys.executable, list_comparator_path],
|
|
1050
1779
|
startupinfo=startupinfo)
|
|
1051
|
-
except:
|
|
1780
|
+
except Exception:
|
|
1052
1781
|
# Final fallback
|
|
1053
1782
|
subprocess.Popen([sys.executable, list_comparator_path])
|
|
1054
1783
|
else:
|
|
@@ -1069,4 +1798,20 @@ class DiffViewerSettingsWidget:
|
|
|
1069
1798
|
|
|
1070
1799
|
def get_settings(self):
|
|
1071
1800
|
"""Get current settings."""
|
|
1072
|
-
return {"option": self.option_var.get()}
|
|
1801
|
+
return {"option": self.option_var.get()}
|
|
1802
|
+
|
|
1803
|
+
def _show_warning(self, title, message):
|
|
1804
|
+
"""Show warning dialog using DialogManager if available, otherwise use messagebox."""
|
|
1805
|
+
if hasattr(self.diff_viewer, '_show_warning'):
|
|
1806
|
+
return self.diff_viewer._show_warning(title, message)
|
|
1807
|
+
else:
|
|
1808
|
+
messagebox.showwarning(title, message, parent=self.parent)
|
|
1809
|
+
return True
|
|
1810
|
+
|
|
1811
|
+
def _show_error(self, title, message):
|
|
1812
|
+
"""Show error dialog using DialogManager if available, otherwise use messagebox."""
|
|
1813
|
+
if hasattr(self.diff_viewer, '_show_error'):
|
|
1814
|
+
return self.diff_viewer._show_error(title, message)
|
|
1815
|
+
else:
|
|
1816
|
+
messagebox.showerror(title, message, parent=self.parent)
|
|
1817
|
+
return True
|