Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/portkeydrop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down
57 changes: 57 additions & 0 deletions src/portkeydrop/dialogs/directory_compare.py
Original file line number Diff line number Diff line change
@@ -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)
163 changes: 163 additions & 0 deletions src/portkeydrop/directory_compare.py
Original file line number Diff line number Diff line change
@@ -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())
3 changes: 3 additions & 0 deletions tests/_wx_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading