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
101 changes: 101 additions & 0 deletions src/portkeydrop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from portkeydrop.dialogs.settings import SettingsDialog
from portkeydrop.dialogs.import_connections import ImportConnectionsDialog
from portkeydrop.dialogs.site_manager import SiteManagerDialog
from portkeydrop.dialogs.workspaces import create_workspace_dialog
from portkeydrop.dialogs.transfer import (
TransferDirection,
TransferStatus,
Expand Down Expand Up @@ -66,6 +67,7 @@
)
from portkeydrop.ui.dialogs.migration_dialog import MigrationDialog
from portkeydrop.ui.dialogs.update_dialog import UpdateAvailableDialog
from portkeydrop.workspaces import WorkspaceBookmark, WorkspaceManager

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -95,6 +97,8 @@
ID_SETTINGS = wx.NewIdRef()
ID_CHECK_UPDATES = wx.NewIdRef()
ID_IMPORT_CONNECTIONS = wx.NewIdRef()
ID_SAVE_WORKSPACE = wx.NewIdRef()
ID_OPEN_WORKSPACE = wx.NewIdRef()
ID_RETRY_LAST_FAILED = wx.NewIdRef()
ID_SWITCH_PANE_FOCUS = wx.NewIdRef()
ID_FOCUS_ADDRESS_BAR = wx.NewIdRef()
Expand Down Expand Up @@ -125,6 +129,7 @@ def __init__(self) -> None:
self.build_tag = os.environ.get("PORTKEYDROP_BUILD_TAG")
self._auto_update_check_timer: wx.Timer | None = None
self._site_manager = SiteManager()
self._workspace_manager = WorkspaceManager()
self._transfer_service = TransferService(
notify_window=self,
max_workers=self._settings.transfer.concurrent_transfers,
Expand Down Expand Up @@ -224,6 +229,16 @@ def _build_menu(self) -> None:
sites_menu.Append(
ID_SAVE_CONNECTION, "Sa&ve Current Connection...", "Save active connection as a site"
)
sites_menu.Append(
ID_SAVE_WORKSPACE,
"Save Current &Workspace...",
"Save current local and remote folders as a workspace",
)
sites_menu.Append(
ID_OPEN_WORKSPACE,
"Open &Workspace...",
"Open a saved local and remote workspace",
)
sites_menu.AppendSeparator()
sites_menu.Append(
ID_IMPORT_CONNECTIONS,
Expand Down Expand Up @@ -454,6 +469,8 @@ def _bind_events(self) -> None:
self.Bind(wx.EVT_MENU, self._on_site_manager, id=ID_SITE_MANAGER)
self.Bind(wx.EVT_MENU, self._on_quick_connect, id=ID_QUICK_CONNECT)
self.Bind(wx.EVT_MENU, self._on_save_connection, id=ID_SAVE_CONNECTION)
self.Bind(wx.EVT_MENU, self._on_save_workspace, id=ID_SAVE_WORKSPACE)
self.Bind(wx.EVT_MENU, self._on_open_workspace, id=ID_OPEN_WORKSPACE)
self.Bind(wx.EVT_MENU, self._on_transfer, id=ID_TRANSFER)
self.Bind(wx.EVT_MENU, self._on_upload, id=ID_UPLOAD)
self.Bind(wx.EVT_MENU, self._on_download, id=ID_DOWNLOAD)
Expand Down Expand Up @@ -603,6 +620,90 @@ def _on_save_connection(self, event: wx.CommandEvent) -> None:
self._announce(f"Site '{name}' saved")
dlg.Destroy()

def _on_save_workspace(self, event: wx.CommandEvent) -> None:
if not self._client or not self._client.connected:
wx.MessageBox(
"Connect to a remote server before saving a workspace.",
"Save Workspace",
wx.OK | wx.ICON_INFORMATION,
self,
)
return

local_label = Path(self._local_cwd).name or self._local_cwd
remote_label = PurePosixPath(self._client.cwd).name or self._client.cwd
default_name = f"{local_label} to {remote_label}"
dlg = wx.TextEntryDialog(self, "Workspace name:", "Save Workspace", default_name)
dlg.SetName("Save Workspace")
if dlg.ShowModal() == wx.ID_OK:
name = dlg.GetValue().strip() or default_name
workspace = WorkspaceBookmark(
name=name,
local_path=self._local_cwd,
remote_path=self._client.cwd,
)
self._workspace_manager.add(workspace)
self._announce(f"Workspace '{name}' saved")
dlg.Destroy()

def _on_open_workspace(self, event: wx.CommandEvent) -> None:
if not self._workspace_manager.workspaces:
wx.MessageBox(
"No saved workspaces yet.",
"Open Workspace",
wx.OK | wx.ICON_INFORMATION,
self,
)
return
if not self._client or not self._client.connected:
wx.MessageBox(
"Connect to a remote server before opening a workspace.",
"Open Workspace",
wx.OK | wx.ICON_INFORMATION,
self,
)
return

dlg = create_workspace_dialog(self, self._workspace_manager)
workspace = None
if dlg.ShowModal() == wx.ID_OK:
workspace = dlg.selected_workspace
dlg.Destroy()
if workspace:
self._open_workspace(workspace)

def _open_workspace(self, workspace: WorkspaceBookmark) -> None:
try:
self._set_local_cwd(workspace.local_path)
self._refresh_local_files()
except Exception as exc:
wx.MessageBox(
f"Failed to open local workspace folder: {exc}",
"Open Workspace",
wx.OK | wx.ICON_ERROR,
self,
)
return

self._status(f"Opening workspace {workspace.name}...")
self._announce(f"Opening workspace {workspace.name}")
client = self._client

def _worker() -> None:
try:
client.chdir(workspace.remote_path)
wx.CallAfter(self._refresh_remote_files)
except Exception as exc:
wx.CallAfter(
wx.MessageBox,
f"Failed to open remote workspace folder: {exc}",
"Open Workspace",
wx.OK | wx.ICON_ERROR,
self,
)

threading.Thread(target=_worker, daemon=True).start()

def _on_import_connections(self, event: wx.CommandEvent) -> None:
dlg = ImportConnectionsDialog(self)
result = dlg.ShowModal()
Expand Down
86 changes: 86 additions & 0 deletions src/portkeydrop/dialogs/workspaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Dialogs for saved workspace bookmarks."""

from __future__ import annotations

import wx

from portkeydrop.workspaces import WorkspaceBookmark, WorkspaceManager


class WorkspaceDialog(wx.Dialog):
"""Select or remove a saved workspace bookmark."""

def __init__(self, parent, manager: WorkspaceManager) -> None:
super().__init__(parent, title="Open Workspace", size=(520, 360))
self.SetName("Open Workspace")
self._manager = manager
self.selected_workspace: WorkspaceBookmark | None = None

panel = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)

label = wx.StaticText(panel, label="Saved &workspaces:")
self.workspace_list = wx.ListBox(panel, style=wx.LB_SINGLE)
self.workspace_list.SetName("Saved workspaces")
if hasattr(label, "SetLabelFor"):
label.SetLabelFor(self.workspace_list)

sizer.Add(label, 0, wx.ALL, 8)
sizer.Add(self.workspace_list, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 8)

button_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.open_button = wx.Button(panel, wx.ID_OK, "&Open")
self.remove_button = wx.Button(panel, label="&Remove")
close_button = wx.Button(panel, label="&Close")
button_sizer.Add(self.open_button, 0, wx.ALL, 4)
button_sizer.Add(self.remove_button, 0, wx.ALL, 4)
button_sizer.Add(close_button, 0, wx.ALL, 4)
sizer.Add(button_sizer, 0, wx.ALL, 4)

panel.SetSizer(sizer)
root_sizer = wx.BoxSizer(wx.VERTICAL)
root_sizer.Add(panel, 1, wx.EXPAND)
self.SetSizer(root_sizer)

self.open_button.Bind(wx.EVT_BUTTON, self._on_open)
self.remove_button.Bind(wx.EVT_BUTTON, self._on_remove)
close_button.Bind(wx.EVT_BUTTON, lambda _event: self.EndModal(wx.ID_CANCEL))
self.workspace_list.Bind(wx.EVT_LISTBOX_DCLICK, self._on_open)

self._reload()

def _reload(self) -> None:
self._workspaces = self._manager.workspaces
labels = [
f"{workspace.name} - {workspace.local_path} -> {workspace.remote_path}"
for workspace in self._workspaces
]
self.workspace_list.Set(labels)
if labels:
self.workspace_list.SetSelection(0)

def _selected_index(self) -> int:
selection = self.workspace_list.GetSelection()
return selection if selection != wx.NOT_FOUND else -1

def _on_open(self, event) -> None:
index = self._selected_index()
if index < 0:
return
self.selected_workspace = self._workspaces[index]
self.EndModal(wx.ID_OK)

def _on_remove(self, event) -> None:
index = self._selected_index()
if index < 0:
return
workspace = self._workspaces[index]
self._manager.remove(workspace.id)
self.selected_workspace = None
self._reload()


def create_workspace_dialog(parent, manager: WorkspaceManager) -> WorkspaceDialog:
"""Create the workspace picker dialog."""

return WorkspaceDialog(parent, manager)
87 changes: 87 additions & 0 deletions src/portkeydrop/workspaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Saved local/remote workspace bookmarks."""

from __future__ import annotations

import json
import logging
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path

from portkeydrop.portable import get_config_dir

logger = logging.getLogger(__name__)

DEFAULT_CONFIG_DIR = get_config_dir()


@dataclass
class WorkspaceBookmark:
"""A non-secret bookmark pairing a local folder with a remote folder."""

id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = ""
local_path: str = ""
remote_path: str = "/"


class WorkspaceManager:
"""Manages saved workspace bookmarks."""

def __init__(self, config_dir: Path = DEFAULT_CONFIG_DIR) -> None:
self._config_dir = config_dir
self._workspaces_path = config_dir / "workspaces.json"
self._workspaces: list[WorkspaceBookmark] = []
self.load()

def load(self) -> None:
if not self._workspaces_path.exists():
self._workspaces = []
return
try:
data = json.loads(self._workspaces_path.read_text(encoding="utf-8"))
self._workspaces = [
WorkspaceBookmark(
**{
key: value
for key, value in item.items()
if key in WorkspaceBookmark.__dataclass_fields__
}
)
for item in data
if isinstance(item, dict)
]
except Exception as exc:
logger.warning(f"Failed to load workspaces: {exc}")
self._workspaces = []

def save(self) -> None:
self._config_dir.mkdir(parents=True, exist_ok=True)
data = [asdict(workspace) for workspace in self._workspaces]
self._workspaces_path.write_text(json.dumps(data, indent=2), encoding="utf-8")

@property
def workspaces(self) -> list[WorkspaceBookmark]:
return list(self._workspaces)

def add(self, workspace: WorkspaceBookmark) -> None:
self._workspaces.append(workspace)
self.save()

def remove(self, workspace_id: str) -> None:
self._workspaces = [
workspace for workspace in self._workspaces if workspace.id != workspace_id
]
self.save()

def get(self, workspace_id: str) -> WorkspaceBookmark | None:
for workspace in self._workspaces:
if workspace.id == workspace_id:
return workspace
return None

def find_by_name(self, name: str) -> WorkspaceBookmark | None:
for workspace in self._workspaces:
if workspace.name.lower() == name.lower():
return workspace
return None
2 changes: 2 additions & 0 deletions tests/_wx_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def Close() -> None:
fake_wx.ACCEL_NORMAL = 1
fake_wx.ACCEL_CTRL = 2
fake_wx.ID_OK = 100
fake_wx.ID_CANCEL = 99
fake_wx.OK = 100
fake_wx.YES = 101
fake_wx.YES_NO = 102
Expand All @@ -156,6 +157,7 @@ def Close() -> None:
fake_wx.EVT_KEY_DOWN = object()
fake_wx.EVT_CONTEXT_MENU = object()
fake_wx.EVT_TEXT_ENTER = object()
fake_wx.EVT_LISTBOX_DCLICK = object()
fake_wx.EVT_TIMER = object()
fake_wx.EVT_CHAR_HOOK = object()
fake_wx.EVT_CLOSE = object()
Expand Down
Loading
Loading