From 1a5acafa1b91754c1fc00649f1e0adb977df152d Mon Sep 17 00:00:00 2001 From: Orinks Date: Fri, 8 May 2026 18:22:08 -0400 Subject: [PATCH] feat(workspaces): reopen paired local and remote folders Add accessible workspace bookmarks so users can save a familiar local/remote folder pair and return to it without rebuilding navigation context each session. Constraint: Store only non-secret folder bookmark data in the normal config directory. Rejected: Sync execution in this branch | too much destructive-behavior risk before preview and confirmation flows mature. Confidence: high Scope-risk: moderate Directive: Keep future workspace automation explicit and non-destructive until users confirm a transfer plan. Tested: .venv\\Scripts\\python.exe -m ruff check .; .venv\\Scripts\\python.exe -m ruff format --check .; .venv\\Scripts\\python.exe -m pytest -q Not-tested: Manual NVDA/JAWS pass against the live wx workspace dialog. --- src/portkeydrop/app.py | 101 ++++++++++++++++++++++++ src/portkeydrop/dialogs/workspaces.py | 86 ++++++++++++++++++++ src/portkeydrop/workspaces.py | 87 ++++++++++++++++++++ tests/_wx_stub.py | 2 + tests/test_app.py | 109 ++++++++++++++++++++++++++ tests/test_workspaces.py | 94 ++++++++++++++++++++++ 6 files changed, 479 insertions(+) create mode 100644 src/portkeydrop/dialogs/workspaces.py create mode 100644 src/portkeydrop/workspaces.py create mode 100644 tests/test_workspaces.py diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py index 8b0983d..b8bea2d 100644 --- a/src/portkeydrop/app.py +++ b/src/portkeydrop/app.py @@ -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, @@ -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__) @@ -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() @@ -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, @@ -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, @@ -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) @@ -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() diff --git a/src/portkeydrop/dialogs/workspaces.py b/src/portkeydrop/dialogs/workspaces.py new file mode 100644 index 0000000..437b1f6 --- /dev/null +++ b/src/portkeydrop/dialogs/workspaces.py @@ -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) diff --git a/src/portkeydrop/workspaces.py b/src/portkeydrop/workspaces.py new file mode 100644 index 0000000..3823d5d --- /dev/null +++ b/src/portkeydrop/workspaces.py @@ -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 diff --git a/tests/_wx_stub.py b/tests/_wx_stub.py index 425b1d7..bb63e09 100644 --- a/tests/_wx_stub.py +++ b/tests/_wx_stub.py @@ -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 @@ -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() diff --git a/tests/test_app.py b/tests/test_app.py index 1c71fe6..34ffbd2 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -39,6 +39,7 @@ def _build_frame(module, tmp_path): settings = SimpleNamespace(display=display, transfer=transfer) fake_manager = MagicMock(jobs=[]) fake_site_manager = MagicMock() + fake_workspace_manager = MagicMock() with ExitStack() as stack: stack.enter_context(patch.object(app, "load_settings", return_value=settings)) @@ -46,6 +47,9 @@ def _build_frame(module, tmp_path): patch.object(app, "resolve_startup_local_folder", return_value=str(tmp_path)) ) stack.enter_context(patch.object(app, "SiteManager", return_value=fake_site_manager)) + stack.enter_context( + patch.object(app, "WorkspaceManager", return_value=fake_workspace_manager) + ) transfer_service_patch = stack.enter_context(patch.object(app, "TransferService")) transfer_service_patch.return_value = fake_manager for method in ( @@ -72,9 +76,11 @@ def _hydrate_frame(module): frame._show_transfer_queue = MagicMock() frame._refresh_local_files = MagicMock() frame._refresh_remote_files = MagicMock() + frame._set_local_cwd = MagicMock() frame._get_selected_local_file = MagicMock() frame._get_selected_remote_file = MagicMock() frame._transfer_service = MagicMock() + frame._workspace_manager = MagicMock() frame._transfer_state_by_id = {} frame._transfer_progress_by_id = {} frame.status_bar = MagicMock(SetStatusText=MagicMock()) @@ -822,6 +828,109 @@ def test_site_manager_connect_applies_connection_defaults(app_module): assert info.host_key_policy == app.HostKeyPolicy.STRICT +def test_save_workspace_requires_connection(app_module): + app, fake_wx = app_module + frame = _hydrate_frame(app_module) + frame._client = None + + frame._on_save_workspace(None) + + fake_wx.MessageBox.assert_called_once() + assert fake_wx.MessageBox.call_args.args[1] == "Save Workspace" + + +def test_save_workspace_records_current_local_and_remote_paths(app_module, tmp_path): + app, fake_wx = app_module + frame = _hydrate_frame(app_module) + frame._client = SimpleNamespace(connected=True, cwd="/srv/reports") + frame._local_cwd = str(tmp_path / "reports") + frame._workspace_manager = MagicMock() + dialog = MagicMock( + ShowModal=MagicMock(return_value=fake_wx.ID_OK), + GetValue=MagicMock(return_value="Daily Reports"), + SetName=MagicMock(), + Destroy=MagicMock(), + ) + + with patch.object(fake_wx, "TextEntryDialog", return_value=dialog): + frame._on_save_workspace(None) + + saved = frame._workspace_manager.add.call_args.args[0] + assert saved.name == "Daily Reports" + assert saved.local_path == str(tmp_path / "reports") + assert saved.remote_path == "/srv/reports" + frame._announce.assert_called_once_with("Workspace 'Daily Reports' saved") + + +def test_open_workspace_requires_saved_workspace(app_module): + app, fake_wx = app_module + frame = _hydrate_frame(app_module) + frame._workspace_manager = MagicMock(workspaces=[]) + frame._client = SimpleNamespace(connected=True, cwd="/srv") + + frame._on_open_workspace(None) + + fake_wx.MessageBox.assert_called_once() + assert fake_wx.MessageBox.call_args.args[1] == "Open Workspace" + + +def test_open_workspace_requires_connection(app_module): + app, fake_wx = app_module + frame = _hydrate_frame(app_module) + frame._workspace_manager = MagicMock( + workspaces=[app.WorkspaceBookmark(name="Reports", local_path=".", remote_path="/srv")] + ) + frame._client = None + + frame._on_open_workspace(None) + + fake_wx.MessageBox.assert_called_once() + assert fake_wx.MessageBox.call_args.args[1] == "Open Workspace" + + +def test_open_workspace_uses_selected_bookmark(app_module): + app, fake_wx = app_module + frame = _hydrate_frame(app_module) + workspace = app.WorkspaceBookmark( + name="Reports", + local_path="C:/Reports", + remote_path="/srv/reports", + ) + frame._workspace_manager = MagicMock(workspaces=[workspace]) + frame._client = SimpleNamespace(connected=True, cwd="/srv", chdir=MagicMock()) + frame._open_workspace = MagicMock() + dialog = MagicMock( + ShowModal=MagicMock(return_value=fake_wx.ID_OK), + selected_workspace=workspace, + Destroy=MagicMock(), + ) + + with patch.object(app, "create_workspace_dialog", return_value=dialog): + frame._on_open_workspace(None) + + frame._open_workspace.assert_called_once_with(workspace) + + +def test_open_workspace_changes_both_panes(app_module): + app, _ = app_module + frame = _hydrate_frame(app_module) + workspace = app.WorkspaceBookmark( + name="Reports", + local_path="C:/Reports", + remote_path="/srv/reports", + ) + frame._client = SimpleNamespace(connected=True, cwd="/srv", chdir=MagicMock()) + + with patch.object(app.threading, "Thread", _ImmediateThread): + frame._open_workspace(workspace) + + frame._set_local_cwd.assert_called_once_with("C:/Reports") + frame._refresh_local_files.assert_called_once() + frame._client.chdir.assert_called_once_with("/srv/reports") + frame._refresh_remote_files.assert_called_once() + frame._announce.assert_called_once_with("Opening workspace Reports") + + def test_on_transfer_update_reports_latest_status(app_module): app, _ = app_module frame = _hydrate_frame(app_module) diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py new file mode 100644 index 0000000..d7f7473 --- /dev/null +++ b/tests/test_workspaces.py @@ -0,0 +1,94 @@ +"""Tests for saved workspace bookmarks.""" + +from __future__ import annotations + +import json + +from portkeydrop.workspaces import WorkspaceBookmark, WorkspaceManager + + +def test_empty_initially(tmp_path): + manager = WorkspaceManager(tmp_path) + + assert manager.workspaces == [] + + +def test_add_persists_workspace(tmp_path): + manager = WorkspaceManager(tmp_path) + workspace = WorkspaceBookmark( + name="Reports", + local_path=str(tmp_path / "reports"), + remote_path="/srv/reports", + ) + + manager.add(workspace) + + reloaded = WorkspaceManager(tmp_path) + assert len(reloaded.workspaces) == 1 + assert reloaded.workspaces[0].name == "Reports" + assert reloaded.workspaces[0].local_path == str(tmp_path / "reports") + assert reloaded.workspaces[0].remote_path == "/srv/reports" + + +def test_remove_workspace(tmp_path): + manager = WorkspaceManager(tmp_path) + workspace = WorkspaceBookmark(name="Reports") + manager.add(workspace) + + manager.remove(workspace.id) + + assert manager.workspaces == [] + + +def test_get_by_id(tmp_path): + manager = WorkspaceManager(tmp_path) + workspace = WorkspaceBookmark(name="Reports") + manager.add(workspace) + + found = manager.get(workspace.id) + + assert found is not None + assert found.name == "Reports" + + +def test_find_by_name_is_case_insensitive(tmp_path): + manager = WorkspaceManager(tmp_path) + manager.add(WorkspaceBookmark(name="Reports")) + + found = manager.find_by_name("reports") + + assert found is not None + assert found.name == "Reports" + + +def test_load_corrupt_file(tmp_path): + (tmp_path / "workspaces.json").write_text("not json", encoding="utf-8") + + manager = WorkspaceManager(tmp_path) + + assert manager.workspaces == [] + + +def test_workspaces_returns_copy(tmp_path): + manager = WorkspaceManager(tmp_path) + manager.add(WorkspaceBookmark(name="Reports")) + workspaces = manager.workspaces + + workspaces.clear() + + assert len(manager.workspaces) == 1 + + +def test_workspace_file_contains_no_secret_connection_fields(tmp_path): + manager = WorkspaceManager(tmp_path) + manager.add( + WorkspaceBookmark( + name="Reports", + local_path=str(tmp_path / "reports"), + remote_path="/srv/reports", + ) + ) + + data = json.loads((tmp_path / "workspaces.json").read_text(encoding="utf-8")) + + assert set(data[0]) == {"id", "name", "local_path", "remote_path"}