Skip to content

Commit babc06c

Browse files
chrisdpurcellclaude
andcommitted
feat: implement file merge tab and preferences dialog (v0.4.0)
Phase 1 — File Merge: - Add TextProcessingService.merge_documents() - Add MainViewModel merge queue (add/remove/reorder/execute slots) - Replace merge 'coming soon' label with full QListWidget UI (drag-drop, up/down buttons, separator field, merge action) - Wire merge tab in MainWindow with all signals and integration tests Phase 2 — Preferences Dialog: - New preferences_dialog.ui with font, word wrap, line numbers, theme, and default directory settings - New PreferencesDialog controller (QObject + QSettings read/write) - _apply_preferences() on startup and on preferences_changed signal: font, word wrap, dark/light theme palette, file tree root directory - 11 unit tests, 6 integration tests Total: 172 tests passing (was 136 before these phases). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0df34be commit babc06c

11 files changed

Lines changed: 2048 additions & 15 deletions

docs/plans/2026-02-21-polish.md

Lines changed: 946 additions & 0 deletions
Large diffs are not rendered by default.

src/services/text_processing_service.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
"""TextProcessingService — stateless text cleaning operations.
1+
"""TextProcessingService — stateless text cleaning and merge operations.
22
33
No Qt imports. No file I/O. Each method is a pure function wrapped in a class
4-
for dependency injection. Called by MainViewModel.apply_cleaning().
4+
for dependency injection. Called by MainViewModel.apply_cleaning() and execute_merge().
55
If the CleaningOptions fields change, update apply_options() below.
66
"""
77

88
import logging
99
import re
1010

1111
from src.models.cleaning_options import CleaningOptions
12+
from src.models.text_document import TextDocument
1213

1314
logger = logging.getLogger(__name__)
1415

@@ -38,6 +39,14 @@ def remove_tabs(self, text: str) -> str:
3839
lines = text.splitlines()
3940
return "\n".join(line.lstrip(" \t") for line in lines)
4041

42+
def merge_documents(self, docs: list[TextDocument], separator: str) -> str:
43+
"""Concatenate document contents with a separator between each.
44+
45+
Empty list returns "". Single document returns its content with no separator.
46+
Separator is inserted between documents, not appended after the last one.
47+
"""
48+
return separator.join(doc.content for doc in docs)
49+
4150
def apply_options(self, text: str, options: CleaningOptions) -> str:
4251
"""Apply enabled cleaning operations in a fixed order.
4352

src/viewmodels/main_viewmodel.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99

1010
import logging
11+
import os
1112
from typing import Protocol
1213

1314
from 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

3538
class 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

Comments
 (0)