tenseleyflow/wulftp / ff0e65e

Browse files

trash, create dirs, remote view state

Authored by espadonne
SHA
ff0e65ecb68f04f26653db5fea2053e243d669f8
Parents
cc8ff9f
Tree
438c06c

2 changed files

StatusFile+-
M src/wulftp/remote_model.py 296 10
M src/wulftp/wulftp.py 488 18
src/wulftp/remote_model.pymodified
@@ -14,6 +14,8 @@ from PyQt6.QtCore import (
1414
     Qt,
1515
     QVariant,
1616
     pyqtSignal,
17
+    QMimeData,
18
+    QUrl,
1719
 )
1820
 from PyQt6.QtGui import QIcon
1921
 
@@ -152,6 +154,7 @@ class RemoteFileSystemModel(QAbstractItemModel):
152154
     # Signals
153155
     directoryLoaded = pyqtSignal(str)  # Emitted when a directory is loaded
154156
     errorOccurred = pyqtSignal(str)    # Emitted on errors
157
+    filesDropped = pyqtSignal(list, str)  # Emitted when files are dropped (files, target_directory)
155158
 
156159
     def __init__(self, sftp: Optional[SFTPClient] = None, parent: Optional[QObject] = None):
157160
         super().__init__(parent)
@@ -176,6 +179,9 @@ class RemoteFileSystemModel(QAbstractItemModel):
176179
         # we cache during a single view session)
177180
         self._path_cache: Dict[str, List[RemoteFileInfo]] = {}
178181
         
182
+        # Track which directories are expanded
183
+        self._expanded_paths: set[str] = set()
184
+
179185
     def set_sftp(self, sftp: SFTPClient):
180186
         """Set or update the SFTP connection"""
181187
         self.beginResetModel()
@@ -191,7 +197,7 @@ class RemoteFileSystemModel(QAbstractItemModel):
191197
         """Toggle showing hidden files"""
192198
         if self.show_hidden != show:
193199
             self.show_hidden = show
194
-            self.refresh()
200
+            self.refresh(preserve_state=True)
195201
 
196202
     def set_show_full_details(self, show: bool):
197203
         """Toggle showing full file details (permissions)"""
@@ -235,8 +241,11 @@ class RemoteFileSystemModel(QAbstractItemModel):
235241
             # Find the parent item for this path
236242
             parent_item = self._find_item_by_path(path)
237243
             if parent_item:
238
-                # Update the parent's children
244
+                # Get the parent index before we modify children
239245
                 parent_index = self._get_index_for_item(parent_item)
246
+                
247
+                # Only call beginInsertRows if we have items to insert
248
+                if items:
240249
                     if parent_index.isValid():
241250
                         self.beginInsertRows(parent_index, 0, len(items) - 1)
242251
                     else:
@@ -246,7 +255,11 @@ class RemoteFileSystemModel(QAbstractItemModel):
246255
                 for item in items:
247256
                     item.parent = parent_item
248257
                 
258
+                if items:
249259
                     self.endInsertRows()
260
+                else:
261
+                    # If no items, just set empty list
262
+                    parent_item.children = []
250263
             
251264
             self.directoryLoaded.emit(path)
252265
             return True
@@ -255,6 +268,164 @@ class RemoteFileSystemModel(QAbstractItemModel):
255268
             self.errorOccurred.emit(str(e))
256269
             return False
257270
 
271
+    def refresh(self, preserve_state: bool = True):
272
+        """Refresh current view"""
273
+        if self.root_item.children is not None:
274
+            if preserve_state:
275
+                # Smart refresh - only reload directories that are already loaded
276
+                self._smart_refresh(self.root_item)
277
+            else:
278
+                # Full refresh - clear everything and start over
279
+                self._path_cache.clear()
280
+                self.beginResetModel()
281
+                self.root_item.children = None
282
+                self.endResetModel()
283
+                self.load_directory("/")
284
+    
285
+    def _smart_refresh(self, item: RemoteFileInfo):
286
+        """Recursively refresh only loaded directories"""
287
+        if item.children is not None and self.sftp:
288
+            # This directory is loaded, refresh it
289
+            try:
290
+                # Fetch fresh listing
291
+                attrs = self.sftp.listdir_attr(item.full_path if item != self.root_item else "/")
292
+                
293
+                # Convert to RemoteFileInfo objects
294
+                new_items = []
295
+                for attr in attrs:
296
+                    name = attr.filename
297
+                    if not self.show_hidden and name.startswith("."):
298
+                        continue
299
+                    
300
+                    path = item.full_path if item != self.root_item else "/"
301
+                    file_info = RemoteFileInfo(attr, name, path)
302
+                    new_items.append(file_info)
303
+                
304
+                # Sort: directories first, then by name
305
+                new_items.sort(key=lambda x: (not x.is_dir, x.name.lower()))
306
+                
307
+                # Find which children need to be preserved (already expanded)
308
+                expanded_children = {}
309
+                if item.children:
310
+                    for child in item.children:
311
+                        if child.children is not None:  # This child was expanded
312
+                            expanded_children[child.name] = child
313
+                
314
+                # Update the model
315
+                parent_index = self._get_index_for_item(item)
316
+                
317
+                # Remove old children
318
+                if item.children:
319
+                    self.beginRemoveRows(parent_index, 0, len(item.children) - 1)
320
+                    item.children = []
321
+                    self.endRemoveRows()
322
+                
323
+                # Add new children
324
+                if new_items:
325
+                    self.beginInsertRows(parent_index, 0, len(new_items) - 1)
326
+                    item.children = new_items
327
+                    for child in new_items:
328
+                        child.parent = item
329
+                        # If this child was previously expanded, restore its state
330
+                        if child.name in expanded_children:
331
+                            old_child = expanded_children[child.name]
332
+                            child.children = old_child.children
333
+                            # Update parent references
334
+                            if child.children:
335
+                                for grandchild in child.children:
336
+                                    grandchild.parent = child
337
+                    self.endInsertRows()
338
+                
339
+                # Recursively refresh expanded children
340
+                for child in item.children or []:
341
+                    if child.children is not None:
342
+                        self._smart_refresh(child)
343
+                
344
+                # Update cache
345
+                path = item.full_path if item != self.root_item else "/"
346
+                self._path_cache[path] = new_items
347
+                
348
+            except Exception as e:
349
+                # Silently handle errors during refresh
350
+                pass
351
+    
352
+    def _refresh_single_directory(self, item: RemoteFileInfo):
353
+        """Refresh a single directory without affecting the rest of the tree"""
354
+        if not self.sftp or item.children is None:
355
+            return
356
+            
357
+        try:
358
+            # Fetch fresh listing
359
+            path = item.full_path if item != self.root_item else "/"
360
+            attrs = self.sftp.listdir_attr(path)
361
+            
362
+            # Convert to RemoteFileInfo objects
363
+            new_items = []
364
+            for attr in attrs:
365
+                name = attr.filename
366
+                if not self.show_hidden and name.startswith("."):
367
+                    continue
368
+                    
369
+                file_info = RemoteFileInfo(attr, name, path)
370
+                new_items.append(file_info)
371
+            
372
+            # Sort: directories first, then by name
373
+            new_items.sort(key=lambda x: (not x.is_dir, x.name.lower()))
374
+            
375
+            # Find which children need to be preserved (already expanded)
376
+            expanded_children = {}
377
+            if item.children:
378
+                for child in item.children:
379
+                    if child.children is not None:  # This child was expanded
380
+                        expanded_children[child.name] = child
381
+            
382
+            # Update the model
383
+            parent_index = self._get_index_for_item(item)
384
+            
385
+            # Remove old children
386
+            if item.children:
387
+                self.beginRemoveRows(parent_index, 0, len(item.children) - 1)
388
+                item.children = []
389
+                self.endRemoveRows()
390
+            
391
+            # Add new children
392
+            if new_items:
393
+                self.beginInsertRows(parent_index, 0, len(new_items) - 1)
394
+                item.children = new_items
395
+                for child in new_items:
396
+                    child.parent = item
397
+                    # If this child was previously expanded, restore its state
398
+                    if child.name in expanded_children:
399
+                        old_child = expanded_children[child.name]
400
+                        child.children = old_child.children
401
+                        # Update parent references
402
+                        if child.children:
403
+                            for grandchild in child.children:
404
+                                grandchild.parent = child
405
+                self.endInsertRows()
406
+            else:
407
+                # No children, just set empty list
408
+                item.children = []
409
+            
410
+            # Update cache
411
+            self._path_cache[path] = new_items
412
+            
413
+        except Exception as e:
414
+            # Silently handle errors during refresh
415
+            pass
416
+
417
+    def mark_expanded(self, path: str):
418
+        """Mark a directory as expanded"""
419
+        self._expanded_paths.add(path)
420
+    
421
+    def mark_collapsed(self, path: str):
422
+        """Mark a directory as collapsed"""
423
+        self._expanded_paths.discard(path)
424
+    
425
+    def is_expanded(self, path: str) -> bool:
426
+        """Check if a directory is marked as expanded"""
427
+        return path in self._expanded_paths
428
+
258429
     def _find_item_by_path(self, path: str) -> Optional[RemoteFileInfo]:
259430
         """Find item by path traversing the tree"""
260431
         if path == "/" or path == "":
@@ -395,7 +566,25 @@ class RemoteFileSystemModel(QAbstractItemModel):
395566
     def canFetchMore(self, parent: QModelIndex) -> bool:
396567
         """Check if we need to fetch children for this item"""
397568
         item = self._get_item(parent)
398
-        return item and item.is_dir and item.children is None
569
+        # Check cache first to avoid redundant fetches
570
+        if item and item.is_dir and item.children is None:
571
+            # Check if we already have it cached
572
+            if item.full_path in self._path_cache:
573
+                # Load from cache instead of fetching
574
+                cached_items = self._path_cache[item.full_path]
575
+                parent_index = self._get_index_for_item(item)
576
+                if cached_items:
577
+                    self.beginInsertRows(parent_index, 0, len(cached_items) - 1)
578
+                item.children = cached_items
579
+                for child in cached_items:
580
+                    child.parent = item
581
+                if cached_items:
582
+                    self.endInsertRows()
583
+                else:
584
+                    item.children = []
585
+                return False
586
+            return True
587
+        return False
399588
 
400589
     def fetchMore(self, parent: QModelIndex):
401590
         """Fetch children for a directory (lazy loading)"""
@@ -421,3 +610,100 @@ class RemoteFileSystemModel(QAbstractItemModel):
421610
     def fileInfo(self, index: QModelIndex) -> Optional[RemoteFileInfo]:
422611
         """Get RemoteFileInfo for an index"""
423612
         return self._get_item(index)
613
+
614
+    # Drag and Drop support
615
+    def supportedDropActions(self) -> Qt.DropAction:
616
+        """Support copy and move operations"""
617
+        return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction
618
+
619
+    def supportedDragActions(self) -> Qt.DropAction:
620
+        """Support copy and move operations"""
621
+        return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction
622
+
623
+    def flags(self, index: QModelIndex) -> Qt.ItemFlag:
624
+        """Set item flags for drag and drop"""
625
+        if not index.isValid():
626
+            return Qt.ItemFlag.NoItemFlags
627
+        
628
+        default_flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
629
+        
630
+        item = self._get_item(index)
631
+        if item:
632
+            # All items can be dragged
633
+            default_flags |= Qt.ItemFlag.ItemIsDragEnabled
634
+            
635
+            # Directories can accept drops
636
+            if item.is_dir:
637
+                default_flags |= Qt.ItemFlag.ItemIsDropEnabled
638
+                
639
+        return default_flags
640
+
641
+    def mimeTypes(self) -> List[str]:
642
+        """Return supported MIME types"""
643
+        return ["text/uri-list", "application/x-qt-windows-mime;value=\"FileNameW\"", "application/x-wulftp-remote"]
644
+
645
+    def mimeData(self, indexes: List[QModelIndex]) -> QMimeData:
646
+        """Create MIME data for drag operation"""
647
+        mime_data = QMimeData()
648
+        
649
+        # Get unique items (avoid duplicates from multiple columns)
650
+        items = []
651
+        paths = []
652
+        for index in indexes:
653
+            if index.column() == 0:  # Only process name column
654
+                item = self._get_item(index)
655
+                if item and item != self.root_item:
656
+                    items.append(item)
657
+                    paths.append(item.full_path)
658
+        
659
+        if items:
660
+            # Store remote paths as custom MIME type
661
+            mime_data.setData("application/x-wulftp-remote", "\n".join(paths).encode('utf-8'))
662
+            
663
+            # Also set text for debugging
664
+            mime_data.setText("\n".join([item.name for item in items]))
665
+        
666
+        return mime_data
667
+
668
+    def canDropMimeData(self, data: QMimeData, action: Qt.DropAction, 
669
+                        row: int, column: int, parent: QModelIndex) -> bool:
670
+        """Check if we can accept the drop"""
671
+        if not self.sftp:
672
+            return False
673
+            
674
+        # Check if we have file URLs (from local file system)
675
+        if data.hasUrls():
676
+            # Can only drop onto directories or root
677
+            if parent.isValid():
678
+                item = self._get_item(parent)
679
+                return item and item.is_dir
680
+            return True  # Can drop to root
681
+            
682
+        return False
683
+
684
+    def dropMimeData(self, data: QMimeData, action: Qt.DropAction,
685
+                     row: int, column: int, parent: QModelIndex) -> bool:
686
+        """Handle the drop - emit signal for main window to handle transfer"""
687
+        if not self.canDropMimeData(data, action, row, column, parent):
688
+            return False
689
+        
690
+        # Get the target directory
691
+        if parent.isValid():
692
+            target_item = self._get_item(parent)
693
+            target_path = target_item.full_path if target_item else "/"
694
+        else:
695
+            target_path = "/"
696
+        
697
+        # Extract local file paths from URLs
698
+        local_paths = []
699
+        if data.hasUrls():
700
+            for url in data.urls():
701
+                if url.isLocalFile():
702
+                    local_paths.append(url.toLocalFile())
703
+        
704
+        if local_paths:
705
+            # Emit signal with files to upload and target directory
706
+            self.filesDropped.emit(local_paths, target_path)
707
+            return True
708
+            
709
+        return False
src/wulftp/wulftp.pymodified
@@ -1,6 +1,7 @@
11
 import os
22
 import stat
33
 import sys
4
+import shutil
45
 from dataclasses import dataclass
56
 from pathlib import Path
67
 from typing import Dict, List, Optional
@@ -19,8 +20,9 @@ from PyQt6.QtCore import (
1920
     QThreadPool,
2021
     pyqtSignal,
2122
     pyqtSlot,
23
+    QMimeData,
2224
 )
23
-from PyQt6.QtGui import QAction, QFileSystemModel, QIcon
25
+from PyQt6.QtGui import QAction, QFileSystemModel, QIcon, QDragEnterEvent, QDropEvent
2426
 from PyQt6.QtWidgets import (
2527
     QAbstractItemView,
2628
     QApplication,
@@ -41,6 +43,8 @@ from PyQt6.QtWidgets import (
4143
     QTreeView,
4244
     QVBoxLayout,
4345
     QWidget,
46
+    QSizePolicy,
47
+    QInputDialog,
4448
 )
4549
 
4650
 # Load environment variables
@@ -99,6 +103,73 @@ class TransferWorker(QRunnable):
99103
             self.signals.progress.emit(progress)
100104
 
101105
 
106
+class LocalTreeView(QTreeView):
107
+    """Custom tree view for local files that handles remote file drops"""
108
+    
109
+    remoteFilesDropped = pyqtSignal(list, str)  # remote_paths, local_directory
110
+    
111
+    def __init__(self, parent=None):
112
+        super().__init__(parent)
113
+        self.setAcceptDrops(True)
114
+        
115
+    def dragEnterEvent(self, event: QDragEnterEvent):
116
+        """Accept drops from remote files"""
117
+        mime_data = event.mimeData()
118
+        
119
+        # Check if this is a remote file drop
120
+        if mime_data.hasFormat("application/x-wulftp-remote"):
121
+            event.acceptProposedAction()
122
+        else:
123
+            # Let the parent handle local file drags
124
+            super().dragEnterEvent(event)
125
+    
126
+    def dragMoveEvent(self, event):
127
+        """Handle drag move events"""
128
+        mime_data = event.mimeData()
129
+        
130
+        if mime_data.hasFormat("application/x-wulftp-remote"):
131
+            # Check if we're over a directory
132
+            index = self.indexAt(event.position().toPoint())
133
+            if index.isValid():
134
+                model = self.model()
135
+                if model and model.isDir(index):
136
+                    event.acceptProposedAction()
137
+                    return
138
+            event.ignore()
139
+        else:
140
+            super().dragMoveEvent(event)
141
+    
142
+    def dropEvent(self, event: QDropEvent):
143
+        """Handle drops from remote files"""
144
+        mime_data = event.mimeData()
145
+        
146
+        if mime_data.hasFormat("application/x-wulftp-remote"):
147
+            # Get the target directory
148
+            index = self.indexAt(event.position().toPoint())
149
+            model = self.model()
150
+            
151
+            if index.isValid() and model:
152
+                if model.isDir(index):
153
+                    target_dir = model.filePath(index)
154
+                else:
155
+                    # Dropped on a file, use its parent directory
156
+                    target_dir = model.filePath(model.parent(index))
157
+            else:
158
+                # Dropped on empty space, use root
159
+                target_dir = model.rootPath() if model else ""
160
+            
161
+            # Get remote paths
162
+            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()]
164
+            
165
+            if remote_paths and target_dir:
166
+                self.remoteFilesDropped.emit(remote_paths, target_dir)
167
+                event.acceptProposedAction()
168
+        else:
169
+            # Let parent handle local file operations
170
+            super().dropEvent(event)
171
+
172
+
102173
 class WulFTPClient(QMainWindow):
103174
     def __init__(self):
104175
         super().__init__()
@@ -156,9 +227,10 @@ class WulFTPClient(QMainWindow):
156227
         central = QWidget()
157228
         self.setCentralWidget(central)
158229
 
159
-        # Main layout
230
+        # Main layout - no margins for maximum space
160231
         layout = QVBoxLayout(central)
161232
         layout.setContentsMargins(0, 0, 0, 0)
233
+        layout.setSpacing(0)
162234
 
163235
         # Status bar (create early so conn_status is available)
164236
         self.status_bar = QStatusBar()
@@ -173,15 +245,12 @@ class WulFTPClient(QMainWindow):
173245
         self.conn_status = QLabel("Disconnected")
174246
         self.status_bar.addPermanentWidget(self.conn_status)
175247
 
176
-        # Create toolbar
248
+        # Create toolbar with connection controls
177249
         self._create_toolbar()
178250
 
179
-        # Connection bar
180
-        conn_widget = self._create_connection_bar()
181
-        layout.addWidget(conn_widget)
182
-
183
-        # Create splitter for two-pane view
251
+        # Create splitter for two-pane view - this is the main content
184252
         splitter = QSplitter(Qt.Orientation.Horizontal)
253
+        splitter.setContentsMargins(0, 0, 0, 0)
185254
 
186255
         # Local pane
187256
         self.local_pane = self._create_file_pane("Local Files", is_local=True)
@@ -194,13 +263,59 @@ class WulFTPClient(QMainWindow):
194263
 
195264
         # Set initial splitter sizes
196265
         splitter.setSizes([600, 600])
266
+        
267
+        # Add splitter to main layout
197268
         layout.addWidget(splitter)
198269
 
199270
     def _create_toolbar(self):
200271
         """Create main toolbar"""
201272
         toolbar = QToolBar()
273
+        toolbar.setMovable(False)  # Keep toolbar in place
202274
         self.addToolBar(toolbar)
203275
 
276
+        # Connection controls in toolbar
277
+        # Host dropdown
278
+        toolbar.addWidget(QLabel(" Host: "))
279
+        self.host_combo = QComboBox()
280
+        self.host_combo.setMinimumWidth(250)
281
+        self.host_combo.setMaximumWidth(350)
282
+
283
+        # Populate hosts
284
+        for host in self.ssh_hosts.values():
285
+            self.host_combo.addItem(host.display_name(), host)
286
+
287
+        # Add custom option
288
+        self.host_combo.addItem("Custom...", None)
289
+        self.host_combo.currentIndexChanged.connect(self._on_host_changed)
290
+        toolbar.addWidget(self.host_combo)
291
+
292
+        # Custom host fields (hidden by default)
293
+        self.custom_host = QLineEdit()
294
+        self.custom_host.setPlaceholderText("hostname")
295
+        self.custom_host.setVisible(False)
296
+        self.custom_host.setMaximumWidth(150)
297
+        toolbar.addWidget(self.custom_host)
298
+
299
+        self.custom_user = QLineEdit()
300
+        self.custom_user.setPlaceholderText("username")
301
+        self.custom_user.setVisible(False)
302
+        self.custom_user.setMaximumWidth(100)
303
+        toolbar.addWidget(self.custom_user)
304
+
305
+        # Connect button
306
+        self.connect_btn = QPushButton()
307
+        self.connect_btn.setIcon(qta.icon("fa5s.plug", color="#4CAF50"))
308
+        self.connect_btn.setText("Connect")
309
+        self.connect_btn.clicked.connect(self.toggle_connection)
310
+        toolbar.addWidget(self.connect_btn)
311
+
312
+        # Connection indicator
313
+        self.conn_indicator = QLabel()
314
+        self._update_connection_status(False)
315
+        toolbar.addWidget(self.conn_indicator)
316
+
317
+        toolbar.addSeparator()
318
+
204319
         # Upload action
205320
         upload_action = QAction(
206321
             qta.icon("fa5s.upload", color="#4CAF50"), "Upload Selected", self
@@ -221,6 +336,17 @@ class WulFTPClient(QMainWindow):
221336
 
222337
         toolbar.addSeparator()
223338
 
339
+        # Delete action
340
+        delete_action = QAction(
341
+            qta.icon("fa5s.trash", color="#F44336"), "Delete Selected", self
342
+        )
343
+        delete_action.triggered.connect(self.delete_selected)
344
+        delete_action.setEnabled(False)
345
+        toolbar.addAction(delete_action)
346
+        self.delete_action = delete_action
347
+
348
+        toolbar.addSeparator()
349
+
224350
         # Refresh action
225351
         refresh_action = QAction(
226352
             qta.icon("fa5s.sync", color="#FF9800"), "Refresh", self
@@ -228,7 +354,13 @@ class WulFTPClient(QMainWindow):
228354
         refresh_action.triggered.connect(self.refresh_views)
229355
         toolbar.addAction(refresh_action)
230356
         
231
-        toolbar.addSeparator()
357
+        # Add spacer to push settings to the right
358
+        spacer = QWidget()
359
+        spacer.setSizePolicy(
360
+            QSizePolicy.Policy.Expanding,
361
+            QSizePolicy.Policy.Expanding
362
+        )
363
+        toolbar.addWidget(spacer)
232364
         
233365
         # Settings actions
234366
         self.show_hidden_action = QAction("Show Hidden Files", self)
@@ -291,6 +423,8 @@ class WulFTPClient(QMainWindow):
291423
         """Create a file browser pane"""
292424
         widget = QWidget()
293425
         layout = QVBoxLayout(widget)
426
+        layout.setContentsMargins(5, 5, 5, 5)
427
+        layout.setSpacing(5)
294428
 
295429
         # Header with navigation
296430
         header = QWidget()
@@ -299,7 +433,7 @@ class WulFTPClient(QMainWindow):
299433
 
300434
         # Title
301435
         title_label = QLabel(title)
302
-        title_label.setStyleSheet("font-weight: bold;")
436
+        title_label.setStyleSheet("font-weight: bold; font-size: 14px;")
303437
         header_layout.addWidget(title_label)
304438
 
305439
         header_layout.addStretch()
@@ -308,18 +442,33 @@ class WulFTPClient(QMainWindow):
308442
         up_btn = QPushButton()
309443
         up_btn.setIcon(qta.icon("fa5s.arrow-up"))
310444
         up_btn.setMaximumSize(30, 30)
445
+        up_btn.setToolTip("Go up one directory")
311446
         header_layout.addWidget(up_btn)
312447
 
313448
         home_btn = QPushButton()
314449
         home_btn.setIcon(qta.icon("fa5s.home"))
315450
         home_btn.setMaximumSize(30, 30)
451
+        home_btn.setToolTip("Go to home directory")
316452
         header_layout.addWidget(home_btn)
317453
 
454
+        # Create directory button
455
+        create_dir_btn = QPushButton()
456
+        create_dir_btn.setIcon(qta.icon("fa5s.folder-plus", color="#FF9800"))
457
+        create_dir_btn.setMaximumSize(30, 30)
458
+        create_dir_btn.setToolTip("Create new directory")
459
+        create_dir_btn.clicked.connect(lambda: self._create_directory(is_local))
460
+        header_layout.addWidget(create_dir_btn)
461
+        
462
+        # Store reference to remote create dir button for enabling/disabling
463
+        if not is_local:
464
+            self.remote_create_dir_btn = create_dir_btn
465
+
318466
         layout.addWidget(header)
319467
 
320468
         # Path display
321469
         path_edit = QLineEdit()
322470
         path_edit.setReadOnly(True)
471
+        path_edit.setStyleSheet("QLineEdit { background-color: #f0f0f0; }")
323472
         layout.addWidget(path_edit)
324473
 
325474
         # File view
@@ -328,11 +477,22 @@ class WulFTPClient(QMainWindow):
328477
             model = QFileSystemModel()
329478
             model.setRootPath(QDir.homePath())
330479
 
331
-            view = QTreeView()
480
+            # Use custom tree view for local files
481
+            view = LocalTreeView()
332482
             view.setModel(model)
333483
             view.setRootIndex(model.index(QDir.homePath()))
334484
             view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
335485
             
486
+            # Enable drag from local view
487
+            view.setDragEnabled(True)
488
+            view.setDefaultDropAction(Qt.DropAction.CopyAction)
489
+            
490
+            # Drop is handled by LocalTreeView
491
+            view.setDropIndicatorShown(True)
492
+            
493
+            # Connect remote file drop signal
494
+            view.remoteFilesDropped.connect(self._handle_remote_files_dropped)
495
+
336496
             # Configure columns
337497
             view.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
338498
             view.setColumnWidth(1, 100)  # Size
@@ -356,6 +516,12 @@ class WulFTPClient(QMainWindow):
356516
             view.setModel(self.remote_model)
357517
             view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
358518
             
519
+            # Enable drag from remote view and drop onto it
520
+            view.setDragEnabled(True)
521
+            view.setAcceptDrops(True)
522
+            view.setDefaultDropAction(Qt.DropAction.CopyAction)
523
+            view.setDropIndicatorShown(True)
524
+            
359525
             # Configure columns
360526
             view.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
361527
             view.setColumnWidth(1, 100)  # Size
@@ -374,6 +540,7 @@ class WulFTPClient(QMainWindow):
374540
             # Connect model signals
375541
             self.remote_model.directoryLoaded.connect(lambda path: self.remote_path.setText(path))
376542
             self.remote_model.errorOccurred.connect(lambda err: QMessageBox.warning(self, "Error", err))
543
+            self.remote_model.filesDropped.connect(self._handle_files_dropped)
377544
             
378545
             # Connect navigation
379546
             up_btn.clicked.connect(lambda: self._navigate_up(False))
@@ -383,6 +550,10 @@ class WulFTPClient(QMainWindow):
383550
             # Connect single click to update path
384551
             view.clicked.connect(lambda idx: self._on_remote_clicked(idx))
385552
             
553
+            # Track expansion state
554
+            view.expanded.connect(self._on_remote_expanded)
555
+            view.collapsed.connect(self._on_remote_collapsed)
556
+
386557
         # Context menu
387558
         view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
388559
         view.customContextMenuRequested.connect(
@@ -406,10 +577,12 @@ class WulFTPClient(QMainWindow):
406577
             icon = qta.icon("fa5s.link", color="#4CAF50")
407578
             self.conn_status.setText("Connected")
408579
             self.connect_btn.setText("Disconnect")
580
+            self.connect_btn.setIcon(qta.icon("fa5s.unlink", color="#F44336"))
409581
         else:
410582
             icon = qta.icon("fa5s.unlink", color="#F44336")
411583
             self.conn_status.setText("Disconnected")
412584
             self.connect_btn.setText("Connect")
585
+            self.connect_btn.setIcon(qta.icon("fa5s.plug", color="#4CAF50"))
413586
 
414587
         self.conn_indicator.setPixmap(icon.pixmap(24, 24))
415588
         
@@ -418,6 +591,8 @@ class WulFTPClient(QMainWindow):
418591
             self.upload_action.setEnabled(connected)
419592
         if hasattr(self, 'download_action'):
420593
             self.download_action.setEnabled(connected)
594
+        if hasattr(self, 'delete_action'):
595
+            self.delete_action.setEnabled(connected)
421596
         if hasattr(self, 'remote_pane'):
422597
             self.remote_pane.setEnabled(connected)
423598
 
@@ -483,7 +658,13 @@ class WulFTPClient(QMainWindow):
483658
             self.status_bar.showMessage(f"Connected to {hostname}", 3000)
484659
 
485660
         except Exception as e:
486
-            QMessageBox.critical(self, "Connection Error", str(e))
661
+            QMessageBox.critical(
662
+                self, 
663
+                "Connection Error", 
664
+                str(e),
665
+                QMessageBox.StandardButton.Ok,
666
+                QMessageBox.StandardButton.Ok
667
+            )
487668
             self._update_connection_status(False)
488669
 
489670
     def disconnect(self):
@@ -505,7 +686,7 @@ class WulFTPClient(QMainWindow):
505686
         """Refresh both file views"""
506687
         # Local is automatically updated by QFileSystemModel
507688
         if self.sftp and self.remote_model:
508
-            self.remote_model.refresh()
689
+            self.remote_model.refresh(preserve_state=True)
509690
 
510691
     def _toggle_hidden_files(self, checked: bool):
511692
         """Toggle showing hidden files in remote view"""
@@ -578,9 +759,26 @@ class WulFTPClient(QMainWindow):
578759
             self.local_view.setRootIndex(index)
579760
             self.local_path.setText(self.local_model.filePath(index))
580761
 
762
+    def _on_remote_expanded(self, index: QModelIndex):
763
+        """Track when a directory is expanded"""
764
+        path = self.remote_model.filePath(index)
765
+        if path:
766
+            self.remote_model.mark_expanded(path)
767
+    
768
+    def _on_remote_collapsed(self, index: QModelIndex):
769
+        """Track when a directory is collapsed"""
770
+        path = self.remote_model.filePath(index)
771
+        if path:
772
+            self.remote_model.mark_collapsed(path)
773
+
581774
     def _show_context_menu(self, pos, is_local: bool):
582775
         """Show context menu for file operations"""
583776
         menu = QMenu()
777
+        view = self.local_view if is_local else self.remote_view
778
+        
779
+        # Get selected items
780
+        selection = view.selectedIndexes()
781
+        has_selection = len(selection) > 0
584782
 
585783
         if self.sftp:  # Only show transfer options when connected
586784
             if is_local:
@@ -588,11 +786,28 @@ class WulFTPClient(QMainWindow):
588786
                     qta.icon("fa5s.upload", color="#4CAF50"), "Upload"
589787
                 )
590788
                 upload_action.triggered.connect(self.upload_selected)
789
+                upload_action.setEnabled(has_selection)
591790
             else:
592791
                 download_action = menu.addAction(
593792
                     qta.icon("fa5s.download", color="#2196F3"), "Download"
594793
                 )
595794
                 download_action.triggered.connect(self.download_selected)
795
+                download_action.setEnabled(has_selection)
796
+
797
+        menu.addSeparator()
798
+        
799
+        # Create directory action
800
+        create_dir_action = menu.addAction(
801
+            qta.icon("fa5s.folder-plus", color="#FF9800"), "Create Directory"
802
+        )
803
+        create_dir_action.triggered.connect(lambda: self._create_directory(is_local))
804
+        
805
+        # Delete action
806
+        if has_selection:
807
+            delete_action = menu.addAction(
808
+                qta.icon("fa5s.trash", color="#F44336"), "Delete"
809
+            )
810
+            delete_action.triggered.connect(lambda: self._delete_selected(is_local))
596811
         
597812
         menu.addSeparator()
598813
 
@@ -600,7 +815,6 @@ class WulFTPClient(QMainWindow):
600815
         refresh_action.triggered.connect(self.refresh_views)
601816
 
602817
         # Show menu at cursor position
603
-        view = self.local_view if is_local else self.remote_view
604818
         menu.exec(view.mapToGlobal(pos))
605819
 
606820
     def upload_selected(self):
@@ -641,7 +855,13 @@ class WulFTPClient(QMainWindow):
641855
         if files:
642856
             self._transfer_files(files, is_upload=False)
643857
         else:
644
-            QMessageBox.information(self, "Download", "Please select files to download (directories not supported yet)")
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
+            )
645865
 
646866
     def _transfer_files(self, files: List[str], is_upload: bool):
647867
         """Transfer files with progress tracking"""
@@ -680,15 +900,265 @@ class WulFTPClient(QMainWindow):
680900
     def _transfer_complete(self):
681901
         """Handle transfer completion"""
682902
         self.progress_bar.setVisible(False)
683
-        if hasattr(self, 'remote_model') and self.remote_model:
684
-            self.remote_model.refresh()
903
+        if hasattr(self, 'remote_model') and self.remote_model and self.sftp:
904
+            # Only refresh the current directory shown in the path
905
+            current_path = self.remote_path.text() or "/"
906
+            parent_item = self.remote_model._find_item_by_path(current_path)
907
+            if parent_item:
908
+                self.remote_model._refresh_single_directory(parent_item)
685909
         self.status_bar.showMessage("Transfer complete", 3000)
686910
 
687911
     @pyqtSlot(str)
688912
     def _transfer_error(self, error):
689913
         """Handle transfer error"""
690914
         self.progress_bar.setVisible(False)
691
-        QMessageBox.critical(self, "Transfer Error", error)
915
+        QMessageBox.critical(
916
+            self, 
917
+            "Transfer Error", 
918
+            error,
919
+            QMessageBox.StandardButton.Ok,
920
+            QMessageBox.StandardButton.Ok
921
+        )
922
+
923
+    def _handle_files_dropped(self, local_paths: List[str], target_directory: str):
924
+        """Handle files dropped onto remote view"""
925
+        if not self.sftp:
926
+            return
927
+            
928
+        # Update remote path to show where files are being uploaded
929
+        self.remote_path.setText(target_directory)
930
+        
931
+        # Upload each file to the target directory
932
+        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
937
+            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
946
+            
947
+            # Create transfer worker
948
+            worker = TransferWorker(self.sftp, local_path, remote_path, True)
949
+            
950
+            # Connect signals
951
+            worker.signals.progress.connect(self._update_progress)
952
+            worker.signals.finished.connect(self._transfer_complete)
953
+            worker.signals.error.connect(self._transfer_error)
954
+            
955
+            # Show progress bar
956
+            self.progress_bar.setVisible(True)
957
+            self.progress_bar.setValue(0)
958
+            
959
+            # Start transfer
960
+            self.threadpool.start(worker)
961
+            
962
+        self.status_bar.showMessage(f"Uploading {len(local_paths)} file(s) to {target_directory}", 3000)
963
+
964
+    def _handle_remote_files_dropped(self, remote_paths: List[str], local_directory: str):
965
+        """Handle remote files dropped onto local view"""
966
+        if not self.sftp:
967
+            return
968
+            
969
+        # Download each file to the target directory
970
+        for remote_path in remote_paths:
971
+            filename = os.path.basename(remote_path)
972
+            local_path = os.path.join(local_directory, filename)
973
+            
974
+            # Create transfer worker for download
975
+            worker = TransferWorker(self.sftp, remote_path, local_path, False)
976
+            
977
+            # Connect signals
978
+            worker.signals.progress.connect(self._update_progress)
979
+            worker.signals.finished.connect(self._transfer_complete_download)
980
+            worker.signals.error.connect(self._transfer_error)
981
+            
982
+            # Show progress bar
983
+            self.progress_bar.setVisible(True)
984
+            self.progress_bar.setValue(0)
985
+            
986
+            # Start transfer
987
+            self.threadpool.start(worker)
988
+            
989
+        self.status_bar.showMessage(f"Downloading {len(remote_paths)} file(s) to {local_directory}", 3000)
990
+
991
+    @pyqtSlot()
992
+    def _transfer_complete_download(self):
993
+        """Handle download completion - no need to refresh remote"""
994
+        self.progress_bar.setVisible(False)
995
+        self.status_bar.showMessage("Download complete", 3000)
996
+
997
+    def _create_directory(self, is_local: bool):
998
+        """Create a new directory"""
999
+        if is_local:
1000
+            current_path = self.local_model.filePath(self.local_view.rootIndex())
1001
+        else:
1002
+            current_path = self.remote_path.text() or "/"
1003
+        
1004
+        # Get directory name from user without stealing focus
1005
+        name, ok = QInputDialog.getText(
1006
+            self, 
1007
+            "Create Directory", 
1008
+            "Directory name:",
1009
+            flags=Qt.WindowType.WindowStaysOnTopHint
1010
+        )
1011
+        
1012
+        if ok and name:
1013
+            try:
1014
+                if is_local:
1015
+                    new_path = os.path.join(current_path, name)
1016
+                    os.makedirs(new_path, exist_ok=True)
1017
+                    self.status_bar.showMessage(f"Created directory: {name}", 3000)
1018
+                else:
1019
+                    if self.sftp:
1020
+                        new_path = os.path.join(current_path, name)
1021
+                        self.sftp.mkdir(new_path)
1022
+                        # Only refresh the parent directory
1023
+                        parent_item = self.remote_model._find_item_by_path(current_path)
1024
+                        if parent_item:
1025
+                            self.remote_model._refresh_single_directory(parent_item)
1026
+                        self.status_bar.showMessage(f"Created remote directory: {name}", 3000)
1027
+            except Exception as e:
1028
+                QMessageBox.warning(
1029
+                    self, 
1030
+                    "Error", 
1031
+                    f"Failed to create directory: {str(e)}",
1032
+                    QMessageBox.StandardButton.Ok,
1033
+                    QMessageBox.StandardButton.Ok
1034
+                )
1035
+    
1036
+    def _delete_selected(self, is_local: bool):
1037
+        """Delete selected files/directories"""
1038
+        view = self.local_view if is_local else self.remote_view
1039
+        selection = view.selectedIndexes()
1040
+        
1041
+        if not selection:
1042
+            return
1043
+        
1044
+        # Get unique items
1045
+        items = []
1046
+        for index in selection:
1047
+            if index.column() == 0:  # Only process name column
1048
+                if is_local:
1049
+                    path = self.local_model.filePath(index)
1050
+                    is_dir = self.local_model.isDir(index)
1051
+                    name = self.local_model.fileName(index)
1052
+                else:
1053
+                    file_info = self.remote_model.fileInfo(index)
1054
+                    if file_info:
1055
+                        path = file_info.full_path
1056
+                        is_dir = file_info.is_dir
1057
+                        name = file_info.name
1058
+                    else:
1059
+                        continue
1060
+                items.append((path, is_dir, name))
1061
+        
1062
+        if not items:
1063
+            return
1064
+        
1065
+        # Confirm deletion without stealing focus
1066
+        msg = f"Delete {len(items)} item(s)?\n\n"
1067
+        for path, is_dir, name in items[:5]:  # Show first 5 items
1068
+            msg += f"{'[DIR] ' if is_dir else ''}{name}\n"
1069
+        if len(items) > 5:
1070
+            msg += f"... and {len(items) - 5} more"
1071
+        
1072
+        reply = QMessageBox.question(
1073
+            self,
1074
+            "Confirm Delete",
1075
+            msg,
1076
+            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1077
+            QMessageBox.StandardButton.No
1078
+        )
1079
+        
1080
+        if reply == QMessageBox.StandardButton.Yes:
1081
+            errors = []
1082
+            for path, is_dir, name in items:
1083
+                try:
1084
+                    if is_local:
1085
+                        if is_dir:
1086
+                            shutil.rmtree(path)
1087
+                        else:
1088
+                            os.remove(path)
1089
+                    else:
1090
+                        if self.sftp:
1091
+                            if is_dir:
1092
+                                self._delete_remote_dir(path)
1093
+                            else:
1094
+                                self.sftp.remove(path)
1095
+                except Exception as e:
1096
+                    errors.append(f"{name}: {str(e)}")
1097
+            
1098
+            if errors:
1099
+                QMessageBox.warning(
1100
+                    self,
1101
+                    "Delete Errors",
1102
+                    "Some items could not be deleted:\n\n" + "\n".join(errors[:10]),
1103
+                    QMessageBox.StandardButton.Ok,
1104
+                    QMessageBox.StandardButton.Ok
1105
+                )
1106
+            else:
1107
+                self.status_bar.showMessage(f"Deleted {len(items)} item(s)", 3000)
1108
+            
1109
+            # Refresh views
1110
+            if is_local:
1111
+                # Local view auto-refreshes
1112
+                pass
1113
+            else:
1114
+                # For remote, only refresh the parent directories of deleted items
1115
+                if self.sftp:
1116
+                    # Get unique parent directories
1117
+                    parent_dirs = set()
1118
+                    for path, is_dir, name in items:
1119
+                        parent_dir = os.path.dirname(path)
1120
+                        parent_dirs.add(parent_dir)
1121
+                    
1122
+                    # Refresh each parent directory
1123
+                    for parent_dir in parent_dirs:
1124
+                        parent_item = self.remote_model._find_item_by_path(parent_dir)
1125
+                        if parent_item:
1126
+                            self.remote_model._refresh_single_directory(parent_item)
1127
+    
1128
+    def _delete_remote_dir(self, path: str):
1129
+        """Recursively delete remote directory"""
1130
+        if not self.sftp:
1131
+            return
1132
+            
1133
+        # List directory contents
1134
+        for item in self.sftp.listdir_attr(path):
1135
+            item_path = os.path.join(path, item.filename)
1136
+            if stat.S_ISDIR(item.st_mode):
1137
+                self._delete_remote_dir(item_path)
1138
+            else:
1139
+                self.sftp.remove(item_path)
1140
+        
1141
+        # Remove empty directory
1142
+        self.sftp.rmdir(path)
1143
+
1144
+    def delete_selected(self):
1145
+        """Delete selected items from whichever view has focus"""
1146
+        # Determine which view has focus or has a selection
1147
+        local_selection = self.local_view.selectedIndexes()
1148
+        remote_selection = self.remote_view.selectedIndexes() if self.sftp else []
1149
+        
1150
+        if local_selection and (not remote_selection or self.local_view.hasFocus()):
1151
+            self._delete_selected(True)
1152
+        elif remote_selection:
1153
+            self._delete_selected(False)
1154
+        else:
1155
+            QMessageBox.information(
1156
+                self,
1157
+                "Delete",
1158
+                "Please select items to delete",
1159
+                QMessageBox.StandardButton.Ok,
1160
+                QMessageBox.StandardButton.Ok
1161
+            )
6921162
 
6931163
 
6941164
 def main():