Skip to content
Merged
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
44 changes: 43 additions & 1 deletion src/portkeydrop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@
from portkeydrop.dialogs.settings import SettingsDialog
from portkeydrop.dialogs.site_manager import SiteManagerDialog
from portkeydrop.dialogs.transfer import (
TransferDirection,
TransferStatus,
TransferManager,
create_transfer_dialog,
get_transfer_event_binder,
)
from portkeydrop.local_files import (
delete_local,
Expand Down Expand Up @@ -75,6 +78,7 @@ def __init__(self) -> None:
self._settings = load_settings()
self._site_manager = SiteManager()
self._transfer_manager = TransferManager(notify_window=self)
self._transfer_state_by_id: dict[int, str] = {}
self._remote_filter_text = ""
self._local_filter_text = ""
self._local_cwd = resolve_startup_local_folder(self._settings)
Expand Down Expand Up @@ -327,6 +331,7 @@ def _bind_events(self) -> None:
self.Bind(wx.EVT_MENU, self._on_transfer_queue, id=ID_TRANSFER_QUEUE)
self.Bind(wx.EVT_MENU, self._on_settings, id=ID_SETTINGS)
self.Bind(wx.EVT_MENU, self._on_about, id=wx.ID_ABOUT)
self.Bind(get_transfer_event_binder(), self._on_transfer_update)

# Toolbar connect button
self.tb_connect_btn.Bind(wx.EVT_BUTTON, self._on_connect_toolbar)
Expand Down Expand Up @@ -962,10 +967,12 @@ def _on_upload(self, event) -> None:
return
self._transfer_manager.add_recursive_upload(self._client, local_path, remote_path)
self._announce(f"Uploading folder {filename}")
self._update_status(f"Uploading folder {filename}...", self._client.cwd)
else:
total = os.path.getsize(local_path)
self._transfer_manager.add_upload(self._client, local_path, remote_path, total)
self._announce(f"Uploading {filename}")
self._update_status(f"Uploading {filename}...", self._client.cwd)
self._show_transfer_queue()

def _get_clipboard_files(self) -> list[str]:
Expand Down Expand Up @@ -1004,6 +1011,9 @@ def _paste_upload(self) -> None:
count += 1
if count:
self._announce(f"Uploading {count} item{'s' if count != 1 else ''} from clipboard")
self._update_status(
f"Uploading {count} item{'s' if count != 1 else ''}...", self._client.cwd
)
self._show_transfer_queue()

def _paste_local(self) -> None:
Expand Down Expand Up @@ -1060,13 +1070,16 @@ def _delete_remote(self) -> None:
)
if result == wx.YES:
try:
self._update_status(f"Deleting {f.name}...", self._client.cwd)
if f.is_dir:
self._client.rmdir(f.path)
else:
self._client.delete(f.path)
self._announce(f"Deleted {f.name}")
self._update_status("Delete complete.", self._client.cwd)
self._refresh_remote_files()
except Exception as e:
self._update_status("Delete failed.", self._client.cwd)
wx.MessageBox(f"Delete failed: {e}", "Error", wx.OK | wx.ICON_ERROR, self)

def _delete_local(self) -> None:
Expand Down Expand Up @@ -1102,10 +1115,13 @@ def _rename_remote(self) -> None:
parent = str(PurePosixPath(f.path).parent)
new_path = f"{parent.rstrip('/')}/{new_name}"
try:
self._update_status(f"Renaming {f.name}...", self._client.cwd)
self._client.rename(f.path, new_path)
self._announce(f"Renamed to {new_name}")
self._update_status("Rename complete.", self._client.cwd)
self._refresh_remote_files()
except Exception as e:
self._update_status("Rename failed.", self._client.cwd)
wx.MessageBox(f"Rename failed: {e}", "Error", wx.OK | wx.ICON_ERROR, self)
dlg.Destroy()

Expand Down Expand Up @@ -1142,10 +1158,13 @@ def _mkdir_remote(self) -> None:
if name:
path = f"{self._client.cwd.rstrip('/')}/{name}"
try:
self._update_status(f"Creating directory {name}...", self._client.cwd)
self._client.mkdir(path)
self._announce(f"Created directory {name}")
self._update_status("Directory created.", self._client.cwd)
self._refresh_remote_files()
except Exception as e:
self._update_status("Create directory failed.", self._client.cwd)
wx.MessageBox(
f"Failed to create directory: {e}", "Error", wx.OK | wx.ICON_ERROR, self
)
Expand Down Expand Up @@ -1182,7 +1201,30 @@ def _on_transfer_queue(self, event: wx.CommandEvent) -> None:
self._show_transfer_queue()

def _on_transfer_update(self, event) -> None:
pass
latest_status_message = None
for transfer in self._transfer_manager.transfers:
current_state = transfer.status.value
previous_state = self._transfer_state_by_id.get(transfer.id)
if current_state == previous_state:
continue

self._transfer_state_by_id[transfer.id] = current_state
direction_label = (
"Upload" if transfer.direction == TransferDirection.UPLOAD else "Download"
)

if transfer.status == TransferStatus.IN_PROGRESS:
latest_status_message = f"{direction_label} in progress..."
elif transfer.status == TransferStatus.COMPLETED:
latest_status_message = f"{direction_label} complete."
elif transfer.status == TransferStatus.FAILED:
latest_status_message = f"{direction_label} failed."
elif transfer.status == TransferStatus.CANCELLED:
latest_status_message = f"{direction_label} cancelled."

if latest_status_message:
current_path = self._client.cwd if self._client and self._client.connected else ""
self._update_status(latest_status_message, current_path)

def _on_settings(self, event: wx.CommandEvent) -> None:
dlg = SettingsDialog(self, self._settings)
Expand Down
19 changes: 16 additions & 3 deletions src/portkeydrop/dialogs/transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

logger = logging.getLogger(__name__)

_TRANSFER_EVENT_BINDER = None
_TRANSFER_EVENT_TYPE = None


class TransferDirection(Enum):
UPLOAD = "upload"
Expand Down Expand Up @@ -318,18 +321,28 @@ def _notify(self) -> None:
try:
import wx

evt = wx.PyCommandEvent(wx.NewEventType(), -1)
_binder, evt_type = _get_wx_event_binder()
evt = wx.PyCommandEvent(evt_type, -1)
wx.PostEvent(self._notify_window, evt)
except Exception:
pass


def _get_wx_event_binder():
"""Lazy creation of wx event type and binder."""
global _TRANSFER_EVENT_BINDER, _TRANSFER_EVENT_TYPE
if _TRANSFER_EVENT_BINDER is not None and _TRANSFER_EVENT_TYPE is not None:
return _TRANSFER_EVENT_BINDER, _TRANSFER_EVENT_TYPE
import wx

evt_type = wx.NewEventType()
return wx.PyEventBinder(evt_type, 1), evt_type
_TRANSFER_EVENT_TYPE = wx.NewEventType()
_TRANSFER_EVENT_BINDER = wx.PyEventBinder(_TRANSFER_EVENT_TYPE, 1)
return _TRANSFER_EVENT_BINDER, _TRANSFER_EVENT_TYPE


def get_transfer_event_binder():
binder, _event_type = _get_wx_event_binder()
return binder


def create_transfer_dialog(parent, transfer_manager: TransferManager):
Expand Down
58 changes: 58 additions & 0 deletions src/portkeydrop/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,22 @@ def _ensure_connected(self) -> ftplib.FTP:
raise ConnectionError("Not connected")
return self._ftp

def _path_exists(self, path: str) -> bool:
ftp = self._ensure_connected()
try:
ftp.sendcmd(f"MLST {path}")
return True
except Exception:
return False

def _is_directory(self, path: str) -> bool:
ftp = self._ensure_connected()
try:
response = ftp.sendcmd(f"MLST {path}")
return "type=dir" in response.lower()
except Exception:
return False

def list_dir(self, path: str = ".") -> list[RemoteFile]:
ftp = self._ensure_connected()
files: list[RemoteFile] = []
Expand Down Expand Up @@ -287,22 +303,36 @@ def read_callback(data: bytes) -> None:
callback(transferred, total)

ftp.storbinary(f"STOR {remote_path}", local_file, block_size, read_callback)
remote_size = ftp.size(remote_path)
if remote_size is None or remote_size != total:
raise RuntimeError(
f"Remote upload verification failed for {remote_path}: expected {total} bytes, "
f"got {remote_size if remote_size is not None else 'unknown'}."
)

def delete(self, path: str) -> None:
ftp = self._ensure_connected()
ftp.delete(path)
if self._path_exists(path):
raise RuntimeError(f"Remote delete verification failed for {path}.")

def rmdir(self, path: str) -> None:
ftp = self._ensure_connected()
ftp.rmd(path)
if self._path_exists(path):
raise RuntimeError(f"Remote directory delete verification failed for {path}.")

def mkdir(self, path: str) -> None:
ftp = self._ensure_connected()
ftp.mkd(path)
if not self._is_directory(path):
raise RuntimeError(f"Remote mkdir verification failed for {path}.")

def rename(self, old_path: str, new_path: str) -> None:
ftp = self._ensure_connected()
ftp.rename(old_path, new_path)
if not self._path_exists(new_path):
raise RuntimeError(f"Remote rename verification failed for {new_path}.")

def stat(self, path: str) -> RemoteFile:
ftp = self._ensure_connected()
Expand Down Expand Up @@ -602,22 +632,50 @@ def progress(transferred: int, total_bytes: int) -> None:
callback(transferred, total_bytes)

sftp.putfo(local_file, remote_path, file_size=total, callback=progress)
attr = sftp.stat(remote_path)
if (attr.st_size or 0) != total:
raise RuntimeError(
f"Remote upload verification failed for {remote_path}: expected {total} bytes, "
f"got {attr.st_size if attr.st_size is not None else 'unknown'}."
)

def delete(self, path: str) -> None:
sftp = self._ensure_connected()
sftp.remove(path)
try:
sftp.stat(path)
except FileNotFoundError:
return
except OSError as exc:
if getattr(exc, "errno", None) == 2:
return
raise
raise RuntimeError(f"Remote delete verification failed for {path}.")

def rmdir(self, path: str) -> None:
sftp = self._ensure_connected()
sftp.rmdir(path)
try:
sftp.stat(path)
except FileNotFoundError:
return
except OSError as exc:
if getattr(exc, "errno", None) == 2:
return
raise
raise RuntimeError(f"Remote directory delete verification failed for {path}.")

def mkdir(self, path: str) -> None:
sftp = self._ensure_connected()
sftp.mkdir(path)
attr = sftp.stat(path)
if not attr.st_mode or not stat.S_ISDIR(attr.st_mode):
raise RuntimeError(f"Remote mkdir verification failed for {path}.")

def rename(self, old_path: str, new_path: str) -> None:
sftp = self._ensure_connected()
sftp.rename(old_path, new_path)
sftp.stat(new_path)

def stat(self, path: str) -> RemoteFile:
sftp = self._ensure_connected()
Expand Down
Loading
Loading