diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py index 8b0983d..e49ee14 100644 --- a/src/portkeydrop/app.py +++ b/src/portkeydrop/app.py @@ -15,6 +15,8 @@ from portkeydrop import __version__ from portkeydrop.accessible_list import AccessibleReportList, create_report_list, file_row_text +from portkeydrop.directory_compare import compare_directories +from portkeydrop.dialogs.directory_compare import create_directory_compare_dialog from portkeydrop.dialogs.properties import PropertiesDialog from portkeydrop.dialogs.quick_connect import QuickConnectDialog from portkeydrop.dialogs.settings import SettingsDialog @@ -102,6 +104,7 @@ ID_FOCUS_LOCAL_PANE = wx.NewIdRef() ID_FOCUS_REMOTE_PANE = wx.NewIdRef() ID_FOCUS_ACTIVITY_LOG_PANE = wx.NewIdRef() +ID_COMPARE_DIRECTORIES = wx.NewIdRef() class MainFrame(wx.Frame): @@ -194,6 +197,12 @@ def _build_menu(self) -> None: "Hide &Activity Log", "Toggle activity log panel visibility", ) + view_menu.AppendSeparator() + view_menu.Append( + ID_COMPARE_DIRECTORIES, + "Compare &Directories\tCtrl+Shift+C", + "Compare the current local and remote directories", + ) menubar.Append(view_menu, "&View") # Transfer menu @@ -484,6 +493,7 @@ def _bind_events(self) -> None: self._on_focus_activity_log_pane, id=ID_FOCUS_ACTIVITY_LOG_PANE, ) + self.Bind(wx.EVT_MENU, self._on_compare_directories, id=ID_COMPARE_DIRECTORIES) self.Bind(wx.EVT_MENU, self._on_about, id=wx.ID_ABOUT) self.Bind(wx.EVT_CLOSE, self._on_close) self.Bind(get_transfer_event_binder(), self._on_transfer_update) @@ -511,6 +521,7 @@ def _bind_events(self) -> None: entries = [ wx.AcceleratorEntry(wx.ACCEL_NORMAL, wx.WXK_F6, ID_SWITCH_PANE_FOCUS), wx.AcceleratorEntry(wx.ACCEL_CTRL, ord("L"), ID_FOCUS_ADDRESS_BAR), + wx.AcceleratorEntry(wx.ACCEL_CTRL | wx.ACCEL_SHIFT, ord("C"), ID_COMPARE_DIRECTORIES), wx.AcceleratorEntry(wx.ACCEL_CTRL, ord("1"), ID_FOCUS_LOCAL_PANE), wx.AcceleratorEntry(wx.ACCEL_CTRL, ord("2"), ID_FOCUS_REMOTE_PANE), wx.AcceleratorEntry(wx.ACCEL_CTRL, ord("3"), ID_FOCUS_ACTIVITY_LOG_PANE), @@ -1544,6 +1555,22 @@ def _show_transfer_queue(self) -> None: ) self._transfer_dlg.Show() + def _on_compare_directories(self, event) -> None: + """Show a read-only accessible comparison of current local and remote panes.""" + if not self._client or not self._client.connected: + self._announce("Connect to a remote server before comparing directories.") + return + local_files = self._get_visible_files(self._local_files, self._local_filter_text) + remote_files = self._get_visible_files(self._remote_files, self._remote_filter_text) + result = compare_directories(local_files, remote_files) + message = f"Directory comparison: {result.summary}" + self._announce(message) + dialog = create_directory_compare_dialog(self, result) + try: + dialog.ShowModal() + finally: + dialog.Destroy() + # --- File operations (context-aware) --- def _on_delete(self, event) -> None: diff --git a/src/portkeydrop/dialogs/directory_compare.py b/src/portkeydrop/dialogs/directory_compare.py new file mode 100644 index 0000000..4da5c60 --- /dev/null +++ b/src/portkeydrop/dialogs/directory_compare.py @@ -0,0 +1,57 @@ +"""Accessible directory comparison preview dialog.""" + +from __future__ import annotations + +from portkeydrop.directory_compare import CompareResult + + +def create_directory_compare_dialog(parent, result: CompareResult): + """Create a modal read-only directory comparison dialog. Requires wx.""" + import wx + + class DirectoryCompareDialog(wx.Dialog): + def __init__(self, parent_window, compare_result: CompareResult): + super().__init__( + parent_window, + title="Directory Comparison", + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, + size=(720, 420), + ) + self._result = compare_result + self._build_ui() + self._populate() + self.compare_list.SetFocus() + + def _build_ui(self): + root = wx.BoxSizer(wx.VERTICAL) + + summary = wx.StaticText(self, label=f"Summary: {self._result.summary}") + root.Add(summary, 0, wx.EXPAND | wx.ALL, 8) + + wx.StaticText(self, label="Comparison results:") + self.compare_list = wx.ListCtrl(self, style=wx.LC_REPORT | wx.LC_SINGLE_SEL) + self.compare_list.SetName("Directory comparison results") + self.compare_list.InsertColumn(0, "Name", width=220) + self.compare_list.InsertColumn(1, "Action", width=150) + self.compare_list.InsertColumn(2, "Local", width=110) + self.compare_list.InsertColumn(3, "Remote", width=110) + self.compare_list.InsertColumn(4, "Detail", width=260) + root.Add(self.compare_list, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 8) + + buttons = self.CreateStdDialogButtonSizer(wx.OK) + root.Add(buttons, 0, wx.EXPAND | wx.ALL, 8) + self.SetSizer(root) + + def _populate(self): + for row in self._result.rows: + idx = self.compare_list.InsertItem(self.compare_list.GetItemCount(), row.name) + self.compare_list.SetItem(idx, 1, row.action_label) + self.compare_list.SetItem(idx, 2, row.local.display_size if row.local else "") + self.compare_list.SetItem(idx, 3, row.remote.display_size if row.remote else "") + self.compare_list.SetItem(idx, 4, row.detail) + self.compare_list.SetItemData(idx, idx) + if self.compare_list.GetItemCount() > 0: + self.compare_list.Select(0) + self.compare_list.Focus(0) + + return DirectoryCompareDialog(parent, result) diff --git a/src/portkeydrop/directory_compare.py b/src/portkeydrop/directory_compare.py new file mode 100644 index 0000000..ab1380c --- /dev/null +++ b/src/portkeydrop/directory_compare.py @@ -0,0 +1,163 @@ +"""Directory comparison helpers for local/remote file panes.""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + +from portkeydrop.protocols import RemoteFile + + +class CompareAction(Enum): + """Preview action for a local/remote directory comparison row.""" + + SAME = "same" + UPLOAD = "upload" + DOWNLOAD = "download" + LOCAL_NEWER = "local_newer" + REMOTE_NEWER = "remote_newer" + CONFLICT = "conflict" + + +@dataclass(frozen=True) +class CompareRow: + """One named item in a directory comparison.""" + + name: str + action: CompareAction + local: RemoteFile | None = None + remote: RemoteFile | None = None + detail: str = "" + + @property + def action_label(self) -> str: + labels = { + CompareAction.SAME: "No action", + CompareAction.UPLOAD: "Upload", + CompareAction.DOWNLOAD: "Download", + CompareAction.LOCAL_NEWER: "Upload newer local", + CompareAction.REMOTE_NEWER: "Download newer remote", + CompareAction.CONFLICT: "Review conflict", + } + return labels[self.action] + + @property + def speech(self) -> str: + detail = f", {self.detail}" if self.detail else "" + return f"{self.name}: {self.action_label}{detail}" + + +@dataclass(frozen=True) +class CompareResult: + """Complete directory comparison result.""" + + rows: tuple[CompareRow, ...] + + @property + def summary_counts(self) -> Counter[CompareAction]: + return Counter(row.action for row in self.rows) + + @property + def summary(self) -> str: + counts = self.summary_counts + parts = [ + f"{len(self.rows)} item{'s' if len(self.rows) != 1 else ''}", + f"{counts[CompareAction.UPLOAD] + counts[CompareAction.LOCAL_NEWER]} upload", + f"{counts[CompareAction.DOWNLOAD] + counts[CompareAction.REMOTE_NEWER]} download", + f"{counts[CompareAction.CONFLICT]} conflict", + f"{counts[CompareAction.SAME]} unchanged", + ] + return ", ".join(parts) + + +def compare_directories( + local_files: list[RemoteFile], remote_files: list[RemoteFile] +) -> CompareResult: + """Compare two current-directory file lists by name. + + Parent-directory entries are ignored. The result is read-only: actions describe + what a future sync preview would do, but no transfer jobs are created here. + """ + + local_by_name = _index_by_name(local_files) + remote_by_name = _index_by_name(remote_files) + names = sorted(set(local_by_name) | set(remote_by_name), key=str.casefold) + rows = tuple( + _compare_name(name, local_by_name.get(name), remote_by_name.get(name)) for name in names + ) + return CompareResult(rows=rows) + + +def _index_by_name(files: list[RemoteFile]) -> dict[str, RemoteFile]: + return {file.name: file for file in files if file.name != ".."} + + +def _compare_name(name: str, local: RemoteFile | None, remote: RemoteFile | None) -> CompareRow: + if local is None and remote is not None: + return CompareRow( + name=name, action=CompareAction.DOWNLOAD, remote=remote, detail="remote only" + ) + if remote is None and local is not None: + return CompareRow(name=name, action=CompareAction.UPLOAD, local=local, detail="local only") + if local is None or remote is None: # pragma: no cover - guarded above + raise AssertionError("comparison row needs at least one side") + + if local.is_dir != remote.is_dir: + return CompareRow( + name=name, + action=CompareAction.CONFLICT, + local=local, + remote=remote, + detail="file type differs", + ) + if local.is_dir and remote.is_dir: + return CompareRow( + name=name, action=CompareAction.SAME, local=local, remote=remote, detail="directory" + ) + if local.size != remote.size: + return _newer_row( + name, + local, + remote, + f"size differs: {local.display_size} local, {remote.display_size} remote", + ) + if _same_modified(local.modified, remote.modified): + return CompareRow( + name=name, + action=CompareAction.SAME, + local=local, + remote=remote, + detail="same size and date", + ) + return _newer_row(name, local, remote, "same size, modified date differs") + + +def _newer_row(name: str, local: RemoteFile, remote: RemoteFile, detail: str) -> CompareRow: + if local.modified and remote.modified: + if local.modified > remote.modified: + return CompareRow( + name=name, + action=CompareAction.LOCAL_NEWER, + local=local, + remote=remote, + detail=detail, + ) + if remote.modified > local.modified: + return CompareRow( + name=name, + action=CompareAction.REMOTE_NEWER, + local=local, + remote=remote, + detail=detail, + ) + return CompareRow( + name=name, action=CompareAction.CONFLICT, local=local, remote=remote, detail=detail + ) + + +def _same_modified(left: datetime | None, right: datetime | None) -> bool: + if left is None or right is None: + return left is right + return int(left.timestamp()) == int(right.timestamp()) diff --git a/tests/_wx_stub.py b/tests/_wx_stub.py index 425b1d7..f4b0daf 100644 --- a/tests/_wx_stub.py +++ b/tests/_wx_stub.py @@ -136,6 +136,7 @@ def Close() -> None: fake_wx.WXK_TAB = 9 fake_wx.ACCEL_NORMAL = 1 fake_wx.ACCEL_CTRL = 2 + fake_wx.ACCEL_SHIFT = 4 fake_wx.ID_OK = 100 fake_wx.OK = 100 fake_wx.YES = 101 @@ -145,6 +146,8 @@ def Close() -> None: fake_wx.ICON_INFORMATION = 105 fake_wx.ID_EXIT = 200 fake_wx.ID_ABOUT = 201 + fake_wx.DEFAULT_DIALOG_STYLE = 301 + fake_wx.RESIZE_BORDER = 302 fake_wx.StaticBox = lambda *args, **kwargs: _SimpleWidget() fake_wx.StaticBoxSizer = lambda *args, **kwargs: _SimpleWidget() diff --git a/tests/test_app.py b/tests/test_app.py index 1c71fe6..926db91 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -174,6 +174,11 @@ def test_bind_events_sets_f6_and_ctrl_l_accelerators(app_module): app.ID_SWITCH_PANE_FOCUS, ) in table_entries assert (fake_wx.ACCEL_CTRL, ord("L"), app.ID_FOCUS_ADDRESS_BAR) in table_entries + assert ( + fake_wx.ACCEL_CTRL | fake_wx.ACCEL_SHIFT, + ord("C"), + app.ID_COMPARE_DIRECTORIES, + ) in table_entries def test_macos_menu_uses_command_q_for_exit_not_disconnect(app_module): @@ -262,6 +267,42 @@ def test_focus_address_bar_sets_toolbar_host_focus_and_announces(app_module): frame._announce.assert_called_once_with("Address bar") +def test_compare_directories_requires_remote_connection(app_module): + app, _ = app_module + frame = _hydrate_frame(app_module) + frame._client = None + + frame._on_compare_directories(None) + + frame._announce.assert_called_once_with( + "Connect to a remote server before comparing directories." + ) + + +def test_compare_directories_announces_summary_and_opens_dialog(app_module): + app, _ = app_module + frame = _hydrate_frame(app_module) + frame._client = MagicMock(connected=True) + frame._local_filter_text = "" + frame._remote_filter_text = "" + frame._settings.display.show_hidden_files = True + frame._local_files = [app.RemoteFile(name="local.txt", path="/local.txt")] + frame._remote_files = [app.RemoteFile(name="remote.txt", path="/remote.txt")] + dialog = MagicMock() + + with patch.object( + app, "create_directory_compare_dialog", return_value=dialog + ) as dialog_factory: + frame._on_compare_directories(None) + + frame._announce.assert_called_once_with( + "Directory comparison: 2 items, 1 upload, 1 download, 0 conflict, 0 unchanged" + ) + dialog_factory.assert_called_once() + dialog.ShowModal.assert_called_once() + dialog.Destroy.assert_called_once() + + def test_on_upload_directory_updates_status(app_module): app, _ = app_module frame = _hydrate_frame(app_module) diff --git a/tests/test_directory_compare.py b/tests/test_directory_compare.py new file mode 100644 index 0000000..6efaf42 --- /dev/null +++ b/tests/test_directory_compare.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from datetime import datetime + +from portkeydrop.directory_compare import CompareAction, compare_directories +from portkeydrop.protocols import RemoteFile + + +def _file(name: str, size: int = 10, modified: datetime | None = None) -> RemoteFile: + return RemoteFile(name=name, path=f"/{name}", size=size, modified=modified) + + +def _dir(name: str) -> RemoteFile: + return RemoteFile(name=name, path=f"/{name}", is_dir=True) + + +def test_compare_directories_reports_uploads_downloads_and_same_items(): + modified = datetime(2026, 5, 8, 12, 0) + + result = compare_directories( + [_file("local.txt"), _file("same.txt", modified=modified), RemoteFile("..", "/")], + [_file("remote.txt"), _file("same.txt", modified=modified), RemoteFile("..", "/")], + ) + + actions = {row.name: row.action for row in result.rows} + assert actions == { + "local.txt": CompareAction.UPLOAD, + "remote.txt": CompareAction.DOWNLOAD, + "same.txt": CompareAction.SAME, + } + assert "3 items" in result.summary + assert "1 upload" in result.summary + assert "1 download" in result.summary + + +def test_compare_directories_prefers_newer_side_when_sizes_differ(): + older = datetime(2026, 5, 8, 12, 0) + newer = datetime(2026, 5, 8, 13, 0) + + result = compare_directories( + [_file("report.txt", size=20, modified=newer)], + [_file("report.txt", size=10, modified=older)], + ) + + row = result.rows[0] + assert row.action is CompareAction.LOCAL_NEWER + assert row.action_label == "Upload newer local" + assert "size differs" in row.detail + assert row.speech.startswith("report.txt: Upload newer local") + + +def test_compare_directories_flags_type_conflicts(): + result = compare_directories([_dir("assets")], [_file("assets")]) + + assert result.rows[0].action is CompareAction.CONFLICT + assert result.rows[0].detail == "file type differs"