tenseleyflow/wulftp / 24cbe9c

Browse files

transfer dirs, fix remote->local drag and drop

Authored by espadonne
SHA
24cbe9cae52932d1cf75436abdff0360869f9d90
Parents
ff0e65e
Tree
39f8fd6

2 changed files

StatusFile+-
M src/wulftp/remote_model.py 4 2
M src/wulftp/wulftp.py 336 48
src/wulftp/remote_model.pymodified
@@ -654,10 +654,12 @@ class RemoteFileSystemModel(QAbstractItemModel):
654654
                 item = self._get_item(index)
655655
                 if item and item != self.root_item:
656656
                     items.append(item)
657
-                    paths.append(item.full_path)
657
+                    # Include directory flag in the path data
658
+                    path_info = f"{item.full_path}|{'D' if item.is_dir else 'F'}"
659
+                    paths.append(path_info)
658660
         
659661
         if items:
660
-            # Store remote paths as custom MIME type
662
+            # Store remote paths with type info as custom MIME type
661663
             mime_data.setData("application/x-wulftp-remote", "\n".join(paths).encode('utf-8'))
662664
             
663665
             # Also set text for debugging
src/wulftp/wulftp.pymodified
@@ -103,10 +103,107 @@ class TransferWorker(QRunnable):
103103
             self.signals.progress.emit(progress)
104104
 
105105
 
106
+class DirectoryTransferWorker(QRunnable):
107
+    """Worker for directory transfers"""
108
+    
109
+    def __init__(self, sftp, source_dir, dest_dir, is_upload=True):
110
+        super().__init__()
111
+        self.sftp = sftp
112
+        self.source_dir = source_dir
113
+        self.dest_dir = dest_dir
114
+        self.is_upload = is_upload
115
+        self.signals = WorkerSignals()
116
+        self.total_files = 0
117
+        self.processed_files = 0
118
+        
119
+    def run(self):
120
+        try:
121
+            if self.is_upload:
122
+                self._upload_directory(self.source_dir, self.dest_dir)
123
+            else:
124
+                self._download_directory(self.source_dir, self.dest_dir)
125
+            self.signals.finished.emit()
126
+        except Exception as e:
127
+            self.signals.error.emit(str(e))
128
+    
129
+    def _count_files(self, local_path):
130
+        """Count total files in a local directory recursively"""
131
+        count = 0
132
+        for root, dirs, files in os.walk(local_path):
133
+            count += len(files)
134
+        return count
135
+    
136
+    def _count_remote_files(self, remote_path):
137
+        """Count total files in a remote directory recursively"""
138
+        count = 0
139
+        try:
140
+            for item in self.sftp.listdir_attr(remote_path):
141
+                item_path = os.path.join(remote_path, item.filename)
142
+                if stat.S_ISDIR(item.st_mode):
143
+                    count += self._count_remote_files(item_path)
144
+                else:
145
+                    count += 1
146
+        except:
147
+            pass
148
+        return count
149
+    
150
+    def _upload_directory(self, local_path, remote_path):
151
+        """Recursively upload a directory"""
152
+        # Count total files first
153
+        if self.total_files == 0:
154
+            self.total_files = self._count_files(local_path)
155
+        
156
+        # Create remote directory
157
+        try:
158
+            self.sftp.mkdir(remote_path)
159
+        except:
160
+            # Directory might already exist
161
+            pass
162
+        
163
+        # Upload contents
164
+        for item in os.listdir(local_path):
165
+            local_item = os.path.join(local_path, item)
166
+            remote_item = os.path.join(remote_path, item).replace('\\', '/')
167
+            
168
+            if os.path.isdir(local_item):
169
+                self._upload_directory(local_item, remote_item)
170
+            else:
171
+                # Upload file
172
+                self.sftp.put(local_item, remote_item)
173
+                self.processed_files += 1
174
+                if self.total_files > 0:
175
+                    progress = int((self.processed_files / self.total_files) * 100)
176
+                    self.signals.progress.emit(progress)
177
+    
178
+    def _download_directory(self, remote_path, local_path):
179
+        """Recursively download a directory"""
180
+        # Count total files first
181
+        if self.total_files == 0:
182
+            self.total_files = self._count_remote_files(remote_path)
183
+        
184
+        # Create local directory
185
+        os.makedirs(local_path, exist_ok=True)
186
+        
187
+        # Download contents
188
+        for item in self.sftp.listdir_attr(remote_path):
189
+            remote_item = os.path.join(remote_path, item.filename).replace('\\', '/')
190
+            local_item = os.path.join(local_path, item.filename)
191
+            
192
+            if stat.S_ISDIR(item.st_mode):
193
+                self._download_directory(remote_item, local_item)
194
+            else:
195
+                # Download file
196
+                self.sftp.get(remote_item, local_item)
197
+                self.processed_files += 1
198
+                if self.total_files > 0:
199
+                    progress = int((self.processed_files / self.total_files) * 100)
200
+                    self.signals.progress.emit(progress)
201
+
202
+
106203
 class LocalTreeView(QTreeView):
107204
     """Custom tree view for local files that handles remote file drops"""
108205
     
109
-    remoteFilesDropped = pyqtSignal(list, str)  # remote_paths, local_directory
206
+    remoteFilesDropped = pyqtSignal(list, str)  # remote_items (path, is_dir), local_directory
110207
     
111208
     def __init__(self, parent=None):
112209
         super().__init__(parent)
@@ -135,7 +232,8 @@ class LocalTreeView(QTreeView):
135232
                 if model and model.isDir(index):
136233
                     event.acceptProposedAction()
137234
                     return
138
-            event.ignore()
235
+            # Also accept drops on empty space (root directory)
236
+            event.acceptProposedAction()
139237
         else:
140238
             super().dragMoveEvent(event)
141239
     
@@ -158,12 +256,20 @@ class LocalTreeView(QTreeView):
158256
                 # Dropped on empty space, use root
159257
                 target_dir = model.rootPath() if model else ""
160258
             
161
-            # Get remote paths
259
+            # Get remote paths with type info
162260
             remote_data = mime_data.data("application/x-wulftp-remote").data().decode('utf-8')
163
-            remote_paths = [p.strip() for p in remote_data.split('\n') if p.strip()]
261
+            remote_items = []
262
+            for line in remote_data.split('\n'):
263
+                if line.strip() and '|' in line:
264
+                    path, type_flag = line.strip().rsplit('|', 1)
265
+                    is_dir = type_flag == 'D'
266
+                    remote_items.append((path, is_dir))
267
+                elif line.strip():
268
+                    # Fallback for old format
269
+                    remote_items.append((line.strip(), False))
164270
             
165
-            if remote_paths and target_dir:
166
-                self.remoteFilesDropped.emit(remote_paths, target_dir)
271
+            if remote_items and target_dir:
272
+                self.remoteFilesDropped.emit(remote_items, target_dir)
167273
                 event.acceptProposedAction()
168274
         else:
169275
             # Let parent handle local file operations
@@ -266,6 +372,9 @@ class WulFTPClient(QMainWindow):
266372
         
267373
         # Add splitter to main layout
268374
         layout.addWidget(splitter)
375
+        
376
+        # Initial button state update
377
+        self._update_button_states()
269378
 
270379
     def _create_toolbar(self):
271380
         """Create main toolbar"""
@@ -341,7 +450,7 @@ class WulFTPClient(QMainWindow):
341450
             qta.icon("fa5s.trash", color="#F44336"), "Delete Selected", self
342451
         )
343452
         delete_action.triggered.connect(self.delete_selected)
344
-        delete_action.setEnabled(False)
453
+        delete_action.setEnabled(True)  # Always enabled
345454
         toolbar.addAction(delete_action)
346455
         self.delete_action = delete_action
347456
 
@@ -468,7 +577,15 @@ class WulFTPClient(QMainWindow):
468577
         # Path display
469578
         path_edit = QLineEdit()
470579
         path_edit.setReadOnly(True)
471
-        path_edit.setStyleSheet("QLineEdit { background-color: #f0f0f0; }")
580
+        path_edit.setStyleSheet("""
581
+            QLineEdit { 
582
+                background-color: #2b2b2b; 
583
+                color: #ffffff;
584
+                border: 1px solid #555555;
585
+                padding: 4px;
586
+                font-family: monospace;
587
+            }
588
+        """)
472589
         layout.addWidget(path_edit)
473590
 
474591
         # File view
@@ -509,6 +626,9 @@ class WulFTPClient(QMainWindow):
509626
             up_btn.clicked.connect(lambda: self._navigate_up(True))
510627
             home_btn.clicked.connect(lambda: self._navigate_home(True))
511628
             view.doubleClicked.connect(lambda idx: self._on_local_double_click(idx))
629
+            
630
+            # Connect selection change to update button states
631
+            view.selectionModel().selectionChanged.connect(self._update_button_states)
512632
 
513633
         else:
514634
             # Remote file list with the new model
@@ -553,6 +673,9 @@ class WulFTPClient(QMainWindow):
553673
             # Track expansion state
554674
             view.expanded.connect(self._on_remote_expanded)
555675
             view.collapsed.connect(self._on_remote_collapsed)
676
+            
677
+            # Connect selection change to update button states
678
+            view.selectionModel().selectionChanged.connect(self._update_button_states)
556679
 
557680
         # Context menu
558681
         view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
@@ -591,8 +714,7 @@ class WulFTPClient(QMainWindow):
591714
             self.upload_action.setEnabled(connected)
592715
         if hasattr(self, 'download_action'):
593716
             self.download_action.setEnabled(connected)
594
-        if hasattr(self, 'delete_action'):
595
-            self.delete_action.setEnabled(connected)
717
+        # Delete action is always enabled (not dependent on connection)
596718
         if hasattr(self, 'remote_pane'):
597719
             self.remote_pane.setEnabled(connected)
598720
 
@@ -605,6 +727,9 @@ class WulFTPClient(QMainWindow):
605727
 
606728
     def connect(self):
607729
         """Establish SFTP connection"""
730
+        # Store current focus widget
731
+        focus_widget = QApplication.focusWidget()
732
+        
608733
         try:
609734
             host_data = self.host_combo.currentData()
610735
 
@@ -656,6 +781,10 @@ class WulFTPClient(QMainWindow):
656781
             self._update_connection_status(True)
657782
 
658783
             self.status_bar.showMessage(f"Connected to {hostname}", 3000)
784
+            
785
+            # Restore focus
786
+            if focus_widget:
787
+                focus_widget.setFocus()
659788
 
660789
         except Exception as e:
661790
             QMessageBox.critical(
@@ -666,6 +795,10 @@ class WulFTPClient(QMainWindow):
666795
                 QMessageBox.StandardButton.Ok
667796
             )
668797
             self._update_connection_status(False)
798
+            
799
+            # Restore focus even on error
800
+            if focus_widget:
801
+                focus_widget.setFocus()
669802
 
670803
     def disconnect(self):
671804
         """Close SFTP connection"""
@@ -759,6 +892,21 @@ class WulFTPClient(QMainWindow):
759892
             self.local_view.setRootIndex(index)
760893
             self.local_path.setText(self.local_model.filePath(index))
761894
 
895
+    def _update_button_states(self):
896
+        """Update button states based on current selection"""
897
+        # Check if anything is selected in either view
898
+        local_has_selection = bool(self.local_view.selectedIndexes())
899
+        remote_has_selection = bool(self.remote_view.selectedIndexes()) if self.sftp else False
900
+        
901
+        has_any_selection = local_has_selection or remote_has_selection
902
+        
903
+        # Update delete button - make it look disabled when nothing is selected
904
+        if hasattr(self, 'delete_action'):
905
+            if has_any_selection:
906
+                self.delete_action.setIcon(qta.icon("fa5s.trash", color="#F44336"))
907
+            else:
908
+                self.delete_action.setIcon(qta.icon("fa5s.trash", color="#888888"))
909
+
762910
     def _on_remote_expanded(self, index: QModelIndex):
763911
         """Track when a directory is expanded"""
764912
         path = self.remote_model.filePath(index)
@@ -818,7 +966,7 @@ class WulFTPClient(QMainWindow):
818966
         menu.exec(view.mapToGlobal(pos))
819967
 
820968
     def upload_selected(self):
821
-        """Upload selected local files"""
969
+        """Upload selected local files and directories"""
822970
         if not self.sftp:
823971
             return
824972
 
@@ -826,17 +974,57 @@ class WulFTPClient(QMainWindow):
826974
         if not selection:
827975
             return
828976
 
829
-        # Get unique file paths (avoiding duplicates from multiple columns)
830
-        files = set()
977
+        # Get unique file/directory paths (avoiding duplicates from multiple columns)
978
+        items = []
831979
         for index in selection:
832980
             if index.column() == 0:  # Only process name column
833981
                 path = self.local_model.filePath(index)
834
-                files.add(path)
982
+                is_dir = self.local_model.isDir(index)
983
+                items.append((path, is_dir))
835984
 
836
-        self._transfer_files(list(files), is_upload=True)
985
+        # Separate files and directories
986
+        files = [path for path, is_dir in items if not is_dir]
987
+        directories = [path for path, is_dir in items if is_dir]
988
+        
989
+        # Handle directories first
990
+        if directories:
991
+            reply = QMessageBox.question(
992
+                self,
993
+                "Upload Directories",
994
+                f"Upload {len(directories)} director{'y' if len(directories) == 1 else 'ies'}?\n\n" +
995
+                "This will recursively upload all contents.",
996
+                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
997
+                QMessageBox.StandardButton.Yes
998
+            )
999
+            
1000
+            if reply == QMessageBox.StandardButton.Yes:
1001
+                for dir_path in directories:
1002
+                    dirname = os.path.basename(dir_path)
1003
+                    remote_base = self.remote_path.text() or "/"
1004
+                    remote_path = os.path.join(remote_base, dirname).replace('\\', '/')
1005
+                    
1006
+                    worker = DirectoryTransferWorker(self.sftp, dir_path, remote_path, is_upload=True)
1007
+                    
1008
+                    # Connect signals
1009
+                    worker.signals.progress.connect(self._update_progress)
1010
+                    worker.signals.finished.connect(self._transfer_complete)
1011
+                    worker.signals.error.connect(self._transfer_error)
1012
+                    
1013
+                    # Show progress bar
1014
+                    self.progress_bar.setVisible(True)
1015
+                    self.progress_bar.setValue(0)
1016
+                    
1017
+                    # Start transfer
1018
+                    self.threadpool.start(worker)
1019
+                    
1020
+                    self.status_bar.showMessage(f"Uploading directory: {dirname}", 3000)
1021
+        
1022
+        # Handle files
1023
+        if files:
1024
+            self._transfer_files(files, is_upload=True)
8371025
 
8381026
     def download_selected(self):
839
-        """Download selected remote files"""
1027
+        """Download selected remote files and directories"""
8401028
         if not self.sftp:
8411029
             return
8421030
 
@@ -844,24 +1032,53 @@ class WulFTPClient(QMainWindow):
8441032
         if not selection:
8451033
             return
8461034
 
847
-        # Get unique file paths (avoiding duplicates from multiple columns)
848
-        files = []
1035
+        # Get unique file/directory paths (avoiding duplicates from multiple columns)
1036
+        items = []
8491037
         for index in selection:
8501038
             if index.column() == 0:  # Only process name column
8511039
                 file_info = self.remote_model.fileInfo(index)
852
-                if file_info and not file_info.is_dir:
853
-                    files.append(file_info.full_path)
1040
+                if file_info:
1041
+                    items.append((file_info.full_path, file_info.is_dir, file_info.name))
8541042
 
1043
+        # Separate files and directories
1044
+        files = [path for path, is_dir, name in items if not is_dir]
1045
+        directories = [(path, name) for path, is_dir, name in items if is_dir]
1046
+        
1047
+        # Handle directories first
1048
+        if directories:
1049
+            reply = QMessageBox.question(
1050
+                self,
1051
+                "Download Directories",
1052
+                f"Download {len(directories)} director{'y' if len(directories) == 1 else 'ies'}?\n\n" +
1053
+                "This will recursively download all contents.",
1054
+                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1055
+                QMessageBox.StandardButton.Yes
1056
+            )
1057
+            
1058
+            if reply == QMessageBox.StandardButton.Yes:
1059
+                for dir_path, dirname in directories:
1060
+                    local_base = self.local_model.filePath(self.local_view.rootIndex())
1061
+                    local_path = os.path.join(local_base, dirname)
1062
+                    
1063
+                    worker = DirectoryTransferWorker(self.sftp, dir_path, local_path, is_upload=False)
1064
+                    
1065
+                    # Connect signals
1066
+                    worker.signals.progress.connect(self._update_progress)
1067
+                    worker.signals.finished.connect(self._transfer_complete_download)
1068
+                    worker.signals.error.connect(self._transfer_error)
1069
+                    
1070
+                    # Show progress bar
1071
+                    self.progress_bar.setVisible(True)
1072
+                    self.progress_bar.setValue(0)
1073
+                    
1074
+                    # Start transfer
1075
+                    self.threadpool.start(worker)
1076
+                    
1077
+                    self.status_bar.showMessage(f"Downloading directory: {dirname}", 3000)
1078
+
1079
+        # Handle files
8551080
         if files:
8561081
             self._transfer_files(files, is_upload=False)
857
-        else:
858
-            QMessageBox.information(
859
-                self, 
860
-                "Download", 
861
-                "Please select files to download (directories not supported yet)",
862
-                QMessageBox.StandardButton.Ok,
863
-                QMessageBox.StandardButton.Ok
864
-            )
8651082
 
8661083
     def _transfer_files(self, files: List[str], is_upload: bool):
8671084
         """Transfer files with progress tracking"""
@@ -921,28 +1138,57 @@ class WulFTPClient(QMainWindow):
9211138
         )
9221139
 
9231140
     def _handle_files_dropped(self, local_paths: List[str], target_directory: str):
924
-        """Handle files dropped onto remote view"""
1141
+        """Handle files and directories dropped onto remote view"""
9251142
         if not self.sftp:
9261143
             return
9271144
             
9281145
         # Update remote path to show where files are being uploaded
9291146
         self.remote_path.setText(target_directory)
9301147
         
931
-        # Upload each file to the target directory
1148
+        # Separate files and directories
1149
+        files = []
1150
+        directories = []
1151
+        
9321152
         for local_path in local_paths:
933
-            filename = os.path.basename(local_path)
934
-            remote_path = os.path.join(target_directory, filename)
935
-            
936
-            # Skip directories for now
9371153
             if os.path.isdir(local_path):
938
-                QMessageBox.information(
939
-                    self, 
940
-                    "Upload", 
941
-                    f"Directory upload not yet supported: {filename}",
942
-                    QMessageBox.StandardButton.Ok,
943
-                    QMessageBox.StandardButton.Ok
944
-                )
945
-                continue
1154
+                directories.append(local_path)
1155
+            else:
1156
+                files.append(local_path)
1157
+        
1158
+        # Handle directories
1159
+        if directories:
1160
+            reply = QMessageBox.question(
1161
+                self,
1162
+                "Upload Directories",
1163
+                f"Upload {len(directories)} director{'y' if len(directories) == 1 else 'ies'}?\n\n" +
1164
+                "This will recursively upload all contents.",
1165
+                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1166
+                QMessageBox.StandardButton.Yes
1167
+            )
1168
+            
1169
+            if reply == QMessageBox.StandardButton.Yes:
1170
+                for dir_path in directories:
1171
+                    dirname = os.path.basename(dir_path)
1172
+                    remote_path = os.path.join(target_directory, dirname).replace('\\', '/')
1173
+                    
1174
+                    worker = DirectoryTransferWorker(self.sftp, dir_path, remote_path, is_upload=True)
1175
+                    
1176
+                    # Connect signals
1177
+                    worker.signals.progress.connect(self._update_progress)
1178
+                    worker.signals.finished.connect(self._transfer_complete)
1179
+                    worker.signals.error.connect(self._transfer_error)
1180
+                    
1181
+                    # Show progress bar
1182
+                    self.progress_bar.setVisible(True)
1183
+                    self.progress_bar.setValue(0)
1184
+                    
1185
+                    # Start transfer
1186
+                    self.threadpool.start(worker)
1187
+        
1188
+        # Handle files
1189
+        for local_path in files:
1190
+            filename = os.path.basename(local_path)
1191
+            remote_path = os.path.join(target_directory, filename).replace('\\', '/')
9461192
             
9471193
             # Create transfer worker
9481194
             worker = TransferWorker(self.sftp, local_path, remote_path, True)
@@ -959,15 +1205,56 @@ class WulFTPClient(QMainWindow):
9591205
             # Start transfer
9601206
             self.threadpool.start(worker)
9611207
             
962
-        self.status_bar.showMessage(f"Uploading {len(local_paths)} file(s) to {target_directory}", 3000)
1208
+        total_items = len(files) + len(directories)
1209
+        self.status_bar.showMessage(f"Uploading {total_items} item(s) to {target_directory}", 3000)
9631210
 
964
-    def _handle_remote_files_dropped(self, remote_paths: List[str], local_directory: str):
965
-        """Handle remote files dropped onto local view"""
1211
+    def _handle_remote_files_dropped(self, remote_items: List[tuple], local_directory: str):
1212
+        """Handle remote files and directories dropped onto local view"""
9661213
         if not self.sftp:
9671214
             return
1215
+        
1216
+        # Separate files and directories
1217
+        files = []
1218
+        directories = []
1219
+        
1220
+        for remote_path, is_dir in remote_items:
1221
+            if is_dir:
1222
+                dirname = os.path.basename(remote_path)
1223
+                directories.append((remote_path, dirname))
1224
+            else:
1225
+                files.append(remote_path)
1226
+        
1227
+        # Handle directories
1228
+        if directories:
1229
+            reply = QMessageBox.question(
1230
+                self,
1231
+                "Download Directories",
1232
+                f"Download {len(directories)} director{'y' if len(directories) == 1 else 'ies'}?\n\n" +
1233
+                "This will recursively download all contents.",
1234
+                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1235
+                QMessageBox.StandardButton.Yes
1236
+            )
9681237
             
969
-        # Download each file to the target directory
970
-        for remote_path in remote_paths:
1238
+            if reply == QMessageBox.StandardButton.Yes:
1239
+                for remote_path, dirname in directories:
1240
+                    local_path = os.path.join(local_directory, dirname)
1241
+                    
1242
+                    worker = DirectoryTransferWorker(self.sftp, remote_path, local_path, is_upload=False)
1243
+                    
1244
+                    # Connect signals
1245
+                    worker.signals.progress.connect(self._update_progress)
1246
+                    worker.signals.finished.connect(self._transfer_complete_download)
1247
+                    worker.signals.error.connect(self._transfer_error)
1248
+                    
1249
+                    # Show progress bar
1250
+                    self.progress_bar.setVisible(True)
1251
+                    self.progress_bar.setValue(0)
1252
+                    
1253
+                    # Start transfer
1254
+                    self.threadpool.start(worker)
1255
+        
1256
+        # Handle files
1257
+        for remote_path in files:
9711258
             filename = os.path.basename(remote_path)
9721259
             local_path = os.path.join(local_directory, filename)
9731260
             
@@ -986,7 +1273,8 @@ class WulFTPClient(QMainWindow):
9861273
             # Start transfer
9871274
             self.threadpool.start(worker)
9881275
             
989
-        self.status_bar.showMessage(f"Downloading {len(remote_paths)} file(s) to {local_directory}", 3000)
1276
+        total_items = len(files) + len(directories)
1277
+        self.status_bar.showMessage(f"Downloading {total_items} item(s) to {local_directory}", 3000)
9901278
 
9911279
     @pyqtSlot()
9921280
     def _transfer_complete_download(self):