88"""
99
1010import logging
11+ import os
1112from typing import Protocol
1213
1314from PySide6 .QtCore import QObject , Signal , Slot
@@ -31,6 +32,8 @@ class TextServiceProtocol(Protocol):
3132
3233 def apply_options (self , text : str , options : CleaningOptions ) -> str : ...
3334
35+ def merge_documents (self , docs : list [TextDocument ], separator : str ) -> str : ...
36+
3437
3538class MainViewModel (QObject ):
3639 """Presentation logic for the main window.
@@ -44,6 +47,8 @@ class MainViewModel(QObject):
4447 file_saved: Emitted with filepath after a successful save.
4548 error_occurred: Emitted with error message on any failure.
4649 status_changed: Emitted with status bar text.
50+ merge_list_changed: Emitted with list of display names (filename only) when
51+ the merge queue changes. View re-populates mergeFileList on receipt.
4752 """
4853
4954 document_loaded = Signal (str )
@@ -52,6 +57,7 @@ class MainViewModel(QObject):
5257 file_saved = Signal (str )
5358 error_occurred = Signal (str )
5459 status_changed = Signal (str )
60+ merge_list_changed = Signal (list ) # list[str] of display names
5561
5662 def __init__ (
5763 self ,
@@ -62,6 +68,9 @@ def __init__(
6268 self ._file_service = file_service
6369 self ._text_service = text_service
6470 self ._current_document : TextDocument | None = None
71+ # Merge queue — ordered list of absolute paths; separator inserted between files.
72+ self ._merge_filepaths : list [str ] = []
73+ self ._merge_separator : str = "\n "
6574
6675 @Slot (str )
6776 def load_file (self , filepath : str ) -> None :
@@ -201,3 +210,76 @@ def convert_to_utf8(self, current_text: str) -> None:
201210 logger .error (msg )
202211 self .error_occurred .emit (msg )
203212 self .status_changed .emit ("Error converting file" )
213+
214+ # ── Merge queue ────────────────────────────────────────────────────────────
215+
216+ def _emit_merge_list (self ) -> None :
217+ """Emit merge_list_changed with current display names (filename only)."""
218+ names = [os .path .basename (p ) for p in self ._merge_filepaths ]
219+ self .merge_list_changed .emit (names )
220+
221+ @Slot ()
222+ def add_current_to_merge (self ) -> None :
223+ """Append the currently loaded file's path to the merge queue."""
224+ if self ._current_document is None :
225+ self .error_occurred .emit ("No file loaded — open a file first" )
226+ return
227+ path = self ._current_document .filepath
228+ if path not in self ._merge_filepaths :
229+ self ._merge_filepaths .append (path )
230+ self ._emit_merge_list ()
231+
232+ @Slot (list )
233+ def add_files_to_merge (self , filepaths : list [str ]) -> None :
234+ """Append multiple filepaths to the merge queue; silently drop duplicates."""
235+ changed = False
236+ for path in filepaths :
237+ if path not in self ._merge_filepaths :
238+ self ._merge_filepaths .append (path )
239+ changed = True
240+ if changed :
241+ self ._emit_merge_list ()
242+
243+ @Slot (int )
244+ def remove_from_merge (self , index : int ) -> None :
245+ """Remove the item at the given index from the merge queue."""
246+ if 0 <= index < len (self ._merge_filepaths ):
247+ self ._merge_filepaths .pop (index )
248+ self ._emit_merge_list ()
249+
250+ @Slot (int , int )
251+ def move_merge_item (self , from_idx : int , to_idx : int ) -> None :
252+ """Move merge queue item from from_idx to to_idx (before that position)."""
253+ n = len (self ._merge_filepaths )
254+ if from_idx == to_idx or not (0 <= from_idx < n ) or not (0 <= to_idx <= n ):
255+ return
256+ item = self ._merge_filepaths .pop (from_idx )
257+ # Adjust destination index after the pop when moving forward.
258+ insert_at = to_idx if to_idx <= from_idx else to_idx - 1
259+ self ._merge_filepaths .insert (insert_at , item )
260+ self ._emit_merge_list ()
261+
262+ @Slot (str )
263+ def set_merge_separator (self , sep : str ) -> None :
264+ """Update the separator inserted between merged files."""
265+ self ._merge_separator = sep
266+
267+ @Slot ()
268+ def execute_merge (self ) -> None :
269+ """Read all queued files, merge with separator, emit document_loaded."""
270+ if not self ._merge_filepaths :
271+ self .error_occurred .emit ("No files in merge list" )
272+ return
273+ docs : list [TextDocument ] = []
274+ for path in self ._merge_filepaths :
275+ try :
276+ docs .append (self ._file_service .open_file (path ))
277+ except (FileNotFoundError , PermissionError , OSError ) as e :
278+ name = os .path .basename (path )
279+ self .error_occurred .emit (f"Cannot read { name } : { e } " )
280+ return
281+ merged = self ._text_service .merge_documents (docs , self ._merge_separator )
282+ self .document_loaded .emit (merged )
283+ n = len (docs )
284+ noun = "file" if n == 1 else "files"
285+ self .status_changed .emit (f"Merged { n } { noun } " )
0 commit comments