tenseleyflow/wulftp / 0123c7c

Browse files

change up the ui, enable nav on remote

Authored by espadonne
SHA
0123c7ceada625dca0340cc1852f94c92499547e
Parents
8901475
Tree
751a4b4

3 changed files

StatusFile+-
A src/wulftp/remote_model.py 423 0
M src/wulftp/sftp_backend.py 14 4
M src/wulftp/wulftp.py 661 219
src/wulftp/remote_model.pyadded
@@ -0,0 +1,423 @@
1
+import os
2
+import stat
3
+from datetime import datetime
4
+from enum import IntEnum
5
+from typing import Any, Dict, List, Optional, Union
6
+
7
+import qtawesome as qta
8
+from paramiko import SFTPAttributes, SFTPClient
9
+from PyQt6.QtCore import (
10
+    QAbstractItemModel,
11
+    QDateTime,
12
+    QModelIndex,
13
+    QObject,
14
+    Qt,
15
+    QVariant,
16
+    pyqtSignal,
17
+)
18
+from PyQt6.QtGui import QIcon
19
+
20
+
21
+class RemoteFileInfo:
22
+    """Represents a remote file or directory"""
23
+
24
+    def __init__(self, attr: SFTPAttributes, name: str, parent_path: str = ""):
25
+        self.attr = attr
26
+        self.name = name
27
+        self.parent_path = parent_path
28
+        self.full_path = os.path.join(parent_path, name) if parent_path else name
29
+        self.children: Optional[List[RemoteFileInfo]] = None
30
+        self.parent: Optional[RemoteFileInfo] = None
31
+        self._icon: Optional[QIcon] = None
32
+
33
+    @property
34
+    def is_dir(self) -> bool:
35
+        """Check if this is a directory"""
36
+        return stat.S_ISDIR(self.attr.st_mode)
37
+
38
+    @property
39
+    def is_link(self) -> bool:
40
+        """Check if this is a symbolic link"""
41
+        return stat.S_ISLNK(self.attr.st_mode)
42
+
43
+    @property
44
+    def is_hidden(self) -> bool:
45
+        """Check if this is a hidden file (starts with .)"""
46
+        return self.name.startswith(".")
47
+
48
+    @property
49
+    def size_str(self) -> str:
50
+        """Human-readable file size"""
51
+        if self.is_dir:
52
+            return ""
53
+        
54
+        size = self.attr.st_size
55
+        for unit in ["B", "KB", "MB", "GB", "TB"]:
56
+            if size < 1024.0:
57
+                return f"{size:.1f} {unit}"
58
+            size /= 1024.0
59
+        return f"{size:.1f} PB"
60
+
61
+    @property
62
+    def modified_str(self) -> str:
63
+        """Human-readable modification time"""
64
+        if self.attr.st_mtime:
65
+            dt = datetime.fromtimestamp(self.attr.st_mtime)
66
+            return dt.strftime("%Y-%m-%d %H:%M")
67
+        return ""
68
+
69
+    @property
70
+    def permissions_str(self) -> str:
71
+        """Unix-style permissions string"""
72
+        mode = self.attr.st_mode
73
+        perms = ["-"] * 10
74
+
75
+        # File type
76
+        if stat.S_ISDIR(mode):
77
+            perms[0] = "d"
78
+        elif stat.S_ISLNK(mode):
79
+            perms[0] = "l"
80
+        elif stat.S_ISREG(mode):
81
+            perms[0] = "-"
82
+
83
+        # Permissions
84
+        mode_bits = [
85
+            (stat.S_IRUSR, 1, "r"),
86
+            (stat.S_IWUSR, 2, "w"),
87
+            (stat.S_IXUSR, 3, "x"),
88
+            (stat.S_IRGRP, 4, "r"),
89
+            (stat.S_IWGRP, 5, "w"),
90
+            (stat.S_IXGRP, 6, "x"),
91
+            (stat.S_IROTH, 7, "r"),
92
+            (stat.S_IWOTH, 8, "w"),
93
+            (stat.S_IXOTH, 9, "x"),
94
+        ]
95
+
96
+        for bit, pos, char in mode_bits:
97
+            if mode & bit:
98
+                perms[pos] = char
99
+
100
+        return "".join(perms)
101
+
102
+    def icon(self) -> QIcon:
103
+        """Get appropriate icon for file type"""
104
+        if self._icon is None:
105
+            if self.is_link:
106
+                # Symbolic link - show with link indicator
107
+                if self.is_dir:
108
+                    self._icon = qta.icon("fa5s.folder-open", "fa5s.link",
109
+                                         options=[{}, {"scale_factor": 0.6, 
110
+                                                      "offset": (0.4, 0.4)}])
111
+                else:
112
+                    self._icon = qta.icon("fa5s.file", "fa5s.link",
113
+                                         options=[{}, {"scale_factor": 0.6,
114
+                                                      "offset": (0.4, 0.4)}])
115
+            elif self.is_dir:
116
+                self._icon = qta.icon("fa5s.folder", color="#FFD93D")
117
+            else:
118
+                # File icon based on extension
119
+                ext = os.path.splitext(self.name)[1].lower()
120
+                if ext in [".txt", ".md", ".log"]:
121
+                    self._icon = qta.icon("fa5s.file-alt", color="#6C757D")
122
+                elif ext in [".py", ".js", ".cpp", ".c", ".java"]:
123
+                    self._icon = qta.icon("fa5s.file-code", color="#28A745")
124
+                elif ext in [".jpg", ".png", ".gif", ".bmp"]:
125
+                    self._icon = qta.icon("fa5s.file-image", color="#17A2B8")
126
+                elif ext in [".mp3", ".wav", ".flac", ".ogg"]:
127
+                    self._icon = qta.icon("fa5s.file-audio", color="#FD7E14")
128
+                elif ext in [".mp4", ".avi", ".mkv", ".mov"]:
129
+                    self._icon = qta.icon("fa5s.file-video", color="#DC3545")
130
+                elif ext in [".zip", ".tar", ".gz", ".7z", ".rar"]:
131
+                    self._icon = qta.icon("fa5s.file-archive", color="#6F42C1")
132
+                elif ext in [".pdf"]:
133
+                    self._icon = qta.icon("fa5s.file-pdf", color="#DC3545")
134
+                else:
135
+                    self._icon = qta.icon("fa5s.file", color="#6C757D")
136
+        
137
+        return self._icon
138
+
139
+
140
+class RemoteColumn(IntEnum):
141
+    """Column indices for the model"""
142
+    NAME = 0
143
+    SIZE = 1
144
+    TYPE = 2
145
+    MODIFIED = 3
146
+    PERMISSIONS = 4
147
+
148
+
149
+class RemoteFileSystemModel(QAbstractItemModel):
150
+    """Model for displaying remote SFTP file system"""
151
+
152
+    # Signals
153
+    directoryLoaded = pyqtSignal(str)  # Emitted when a directory is loaded
154
+    errorOccurred = pyqtSignal(str)    # Emitted on errors
155
+
156
+    def __init__(self, sftp: Optional[SFTPClient] = None, parent: Optional[QObject] = None):
157
+        super().__init__(parent)
158
+        self.sftp = sftp
159
+        self.root_path = "/"
160
+        
161
+        # Create a proper root item with valid attributes
162
+        root_attr = SFTPAttributes()
163
+        root_attr.st_mode = stat.S_IFDIR | 0o755  # Directory with rwxr-xr-x
164
+        root_attr.st_size = 0
165
+        root_attr.st_mtime = 0
166
+        root_attr.filename = "/"
167
+        
168
+        self.root_item = RemoteFileInfo(root_attr, "/", "")
169
+        self.root_item.children = []
170
+        
171
+        # Settings
172
+        self.show_hidden = False
173
+        self.show_full_details = False
174
+        
175
+        # Cache for performance (even though we're fetching fresh, 
176
+        # we cache during a single view session)
177
+        self._path_cache: Dict[str, List[RemoteFileInfo]] = {}
178
+
179
+    def set_sftp(self, sftp: SFTPClient):
180
+        """Set or update the SFTP connection"""
181
+        self.beginResetModel()
182
+        self.sftp = sftp
183
+        self._path_cache.clear()
184
+        self.root_item.children = None
185
+        self.endResetModel()
186
+        
187
+        if sftp:
188
+            self.load_directory("/")
189
+
190
+    def set_show_hidden(self, show: bool):
191
+        """Toggle showing hidden files"""
192
+        if self.show_hidden != show:
193
+            self.show_hidden = show
194
+            self.refresh()
195
+
196
+    def set_show_full_details(self, show: bool):
197
+        """Toggle showing full file details (permissions)"""
198
+        if self.show_full_details != show:
199
+            self.show_full_details = show
200
+            # Just emit dataChanged for the permissions column
201
+            if self.root_item.children:
202
+                self.dataChanged.emit(
203
+                    self.index(0, RemoteColumn.PERMISSIONS),
204
+                    self.index(self.rowCount() - 1, RemoteColumn.PERMISSIONS)
205
+                )
206
+
207
+    def load_directory(self, path: str) -> bool:
208
+        """Load directory contents from SFTP"""
209
+        if not self.sftp:
210
+            return False
211
+
212
+        try:
213
+            # Normalize path
214
+            path = os.path.normpath(path)
215
+            
216
+            # Fetch directory listing
217
+            attrs = self.sftp.listdir_attr(path)
218
+            
219
+            # Convert to RemoteFileInfo objects
220
+            items = []
221
+            for attr in attrs:
222
+                name = attr.filename
223
+                if not self.show_hidden and name.startswith("."):
224
+                    continue
225
+                    
226
+                file_info = RemoteFileInfo(attr, name, path)
227
+                items.append(file_info)
228
+            
229
+            # Sort: directories first, then by name
230
+            items.sort(key=lambda x: (not x.is_dir, x.name.lower()))
231
+            
232
+            # Update cache
233
+            self._path_cache[path] = items
234
+            
235
+            # Find the parent item for this path
236
+            parent_item = self._find_item_by_path(path)
237
+            if parent_item:
238
+                # Update the parent's children
239
+                parent_index = self._get_index_for_item(parent_item)
240
+                if parent_index.isValid():
241
+                    self.beginInsertRows(parent_index, 0, len(items) - 1)
242
+                else:
243
+                    self.beginInsertRows(QModelIndex(), 0, len(items) - 1)
244
+                
245
+                parent_item.children = items
246
+                for item in items:
247
+                    item.parent = parent_item
248
+                    
249
+                self.endInsertRows()
250
+            
251
+            self.directoryLoaded.emit(path)
252
+            return True
253
+            
254
+        except Exception as e:
255
+            self.errorOccurred.emit(str(e))
256
+            return False
257
+
258
+    def _find_item_by_path(self, path: str) -> Optional[RemoteFileInfo]:
259
+        """Find item by path traversing the tree"""
260
+        if path == "/" or path == "":
261
+            return self.root_item
262
+            
263
+        # Split path and traverse
264
+        parts = path.strip("/").split("/")
265
+        current = self.root_item
266
+        
267
+        for part in parts:
268
+            if current.children is None:
269
+                return None
270
+                
271
+            found = False
272
+            for child in current.children:
273
+                if child.name == part:
274
+                    current = child
275
+                    found = True
276
+                    break
277
+                    
278
+            if not found:
279
+                return None
280
+                
281
+        return current
282
+
283
+    def _get_index_for_item(self, item: RemoteFileInfo) -> QModelIndex:
284
+        """Get QModelIndex for a RemoteFileInfo item"""
285
+        if item == self.root_item:
286
+            return QModelIndex()
287
+            
288
+        if item.parent and item.parent.children:
289
+            row = item.parent.children.index(item)
290
+            return self.createIndex(row, 0, item)
291
+            
292
+        return QModelIndex()
293
+
294
+    def _get_item(self, index: QModelIndex) -> Optional[RemoteFileInfo]:
295
+        """Get RemoteFileInfo from model index"""
296
+        if index.isValid():
297
+            return index.internalPointer()
298
+        return self.root_item
299
+
300
+    # QAbstractItemModel implementation
301
+    def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
302
+        """Number of columns"""
303
+        return 5 if self.show_full_details else 4
304
+
305
+    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
306
+        """Number of rows (files) in a directory"""
307
+        parent_item = self._get_item(parent)
308
+        if parent_item and parent_item.children is not None:
309
+            return len(parent_item.children)
310
+        return 0
311
+
312
+    def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex:
313
+        """Create model index for item"""
314
+        if not self.hasIndex(row, column, parent):
315
+            return QModelIndex()
316
+
317
+        parent_item = self._get_item(parent)
318
+        if parent_item and parent_item.children and row < len(parent_item.children):
319
+            child_item = parent_item.children[row]
320
+            return self.createIndex(row, column, child_item)
321
+
322
+        return QModelIndex()
323
+
324
+    def parent(self, index: QModelIndex) -> QModelIndex:
325
+        """Get parent index"""
326
+        if not index.isValid():
327
+            return QModelIndex()
328
+
329
+        child_item = index.internalPointer()
330
+        parent_item = child_item.parent
331
+
332
+        if parent_item == self.root_item:
333
+            return QModelIndex()
334
+
335
+        # Find row of parent
336
+        if parent_item.parent and parent_item.parent.children:
337
+            row = parent_item.parent.children.index(parent_item)
338
+            return self.createIndex(row, 0, parent_item)
339
+
340
+        return QModelIndex()
341
+
342
+    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
343
+        """Get data for display"""
344
+        if not index.isValid():
345
+            return None
346
+
347
+        item = index.internalPointer()
348
+        col = index.column()
349
+
350
+        if role == Qt.ItemDataRole.DisplayRole:
351
+            if col == RemoteColumn.NAME:
352
+                return item.name
353
+            elif col == RemoteColumn.SIZE:
354
+                return item.size_str
355
+            elif col == RemoteColumn.TYPE:
356
+                if item.is_link:
357
+                    return "Link"
358
+                elif item.is_dir:
359
+                    return "Folder"
360
+                else:
361
+                    ext = os.path.splitext(item.name)[1]
362
+                    return f"{ext[1:].upper()} File" if ext else "File"
363
+            elif col == RemoteColumn.MODIFIED:
364
+                return item.modified_str
365
+            elif col == RemoteColumn.PERMISSIONS and self.show_full_details:
366
+                return item.permissions_str
367
+
368
+        elif role == Qt.ItemDataRole.DecorationRole and col == RemoteColumn.NAME:
369
+            return item.icon()
370
+
371
+        elif role == Qt.ItemDataRole.TextAlignmentRole:
372
+            if col == RemoteColumn.SIZE:
373
+                return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
374
+
375
+        return None
376
+
377
+    def headerData(self, section: int, orientation: Qt.Orientation, 
378
+                   role: int = Qt.ItemDataRole.DisplayRole) -> Any:
379
+        """Get header data"""
380
+        if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
381
+            headers = ["Name", "Size", "Type", "Modified"]
382
+            if self.show_full_details:
383
+                headers.append("Permissions")
384
+            
385
+            if section < len(headers):
386
+                return headers[section]
387
+
388
+        return None
389
+
390
+    def hasChildren(self, parent: QModelIndex = QModelIndex()) -> bool:
391
+        """Check if item has children (is a directory)"""
392
+        item = self._get_item(parent)
393
+        return item.is_dir if item else False
394
+
395
+    def canFetchMore(self, parent: QModelIndex) -> bool:
396
+        """Check if we need to fetch children for this item"""
397
+        item = self._get_item(parent)
398
+        return item and item.is_dir and item.children is None
399
+
400
+    def fetchMore(self, parent: QModelIndex):
401
+        """Fetch children for a directory (lazy loading)"""
402
+        item = self._get_item(parent)
403
+        if item and item.is_dir and item.children is None:
404
+            self.load_directory(item.full_path)
405
+
406
+    def filePath(self, index: QModelIndex) -> str:
407
+        """Get full path for an item"""
408
+        item = self._get_item(index)
409
+        return item.full_path if item else ""
410
+
411
+    def fileName(self, index: QModelIndex) -> str:
412
+        """Get filename for an item"""
413
+        item = self._get_item(index)
414
+        return item.name if item else ""
415
+
416
+    def isDir(self, index: QModelIndex) -> bool:
417
+        """Check if index points to a directory"""
418
+        item = self._get_item(index)
419
+        return item.is_dir if item else False
420
+
421
+    def fileInfo(self, index: QModelIndex) -> Optional[RemoteFileInfo]:
422
+        """Get RemoteFileInfo for an index"""
423
+        return self._get_item(index)
src/wulftp/sftp_backend.pymodified
@@ -1,5 +1,6 @@
11
 import paramiko
22
 
3
+
34
 class SFTPBackend:
45
     """
56
     simple wrapper for SFTP operations using Paramiko.
@@ -9,7 +10,14 @@ class SFTPBackend:
910
         self.ssh = None
1011
         self.sftp = None
1112
 
12
-    def connect(self, host: str, port: int, username: str, key_path: str, passphrase: str | None = None):
13
+    def connect(
14
+        self,
15
+        host: str,
16
+        port: int,
17
+        username: str,
18
+        key_path: str,
19
+        passphrase: str | None = None,
20
+    ):
1321
         """
1422
         establish an SSH connection and open an SFTP session.
1523
         Raises Paramiko exceptions on failure.
@@ -20,10 +28,12 @@ class SFTPBackend:
2028
             pkey = paramiko.RSAKey.from_private_key_file(key_path, password=passphrase)
2129
             self.ssh.connect(hostname=host, port=port, username=username, pkey=pkey)
2230
         else:
23
-            self.ssh.connect(hostname=host, port=port, username=username, key_filename=key_path)
31
+            self.ssh.connect(
32
+                hostname=host, port=port, username=username, key_filename=key_path
33
+            )
2434
         self.sftp = self.ssh.open_sftp()
2535
 
26
-    def listdir(self, path: str = '.') -> list[str]:
36
+    def listdir(self, path: str = ".") -> list[str]:
2737
         """
2838
         return the list of entries in the remote directory.
2939
         """
@@ -48,4 +58,4 @@ class SFTPBackend:
4858
         if self.sftp:
4959
             self.sftp.close()
5060
         if self.ssh:
51
-            self.ssh.close()
61
+            self.ssh.close()
src/wulftp/wulftp.pymodified
@@ -1,267 +1,709 @@
11
 import os
2
+import stat
23
 import sys
4
+from dataclasses import dataclass
5
+from pathlib import Path
6
+from typing import Dict, List, Optional
37
 
48
 import paramiko
59
 import qtawesome as qta
610
 from dotenv import load_dotenv
7
-
8
-from PyQt6.QtCore import Qt, QSize
9
-from PyQt6.QtCore import pyqtSignal, QRunnable, QThreadPool
11
+from paramiko import SSHConfig
12
+from PyQt6.QtCore import (
13
+    QDir,
14
+    QModelIndex,
15
+    QObject,
16
+    QRunnable,
17
+    QSize,
18
+    Qt,
19
+    QThreadPool,
20
+    pyqtSignal,
21
+    pyqtSlot,
22
+)
23
+from PyQt6.QtGui import QAction, QFileSystemModel, QIcon
1024
 from PyQt6.QtWidgets import (
11
-    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
12
-    QLineEdit, QToolButton, QPushButton, QListWidget, QFileDialog, QMessageBox, QFormLayout,
25
+    QAbstractItemView,
26
+    QApplication,
27
+    QCheckBox,
28
+    QComboBox,
29
+    QHBoxLayout,
30
+    QHeaderView,
31
+    QLabel,
32
+    QLineEdit,
33
+    QMainWindow,
34
+    QMenu,
35
+    QMessageBox,
36
+    QProgressBar,
37
+    QPushButton,
38
+    QSplitter,
39
+    QStatusBar,
40
+    QToolBar,
41
+    QTreeView,
42
+    QVBoxLayout,
43
+    QWidget,
1344
 )
14
-from .sftp_backend import SFTPBackend
45
+
46
+# Load environment variables
47
+load_dotenv()
48
+
49
+
50
+@dataclass
51
+class SSHHost:
52
+    """Represents an SSH host configuration"""
53
+
54
+    alias: str
55
+    hostname: str
56
+    port: int = 22
57
+    user: str = None
58
+    key_file: str = None
59
+
60
+    def display_name(self):
61
+        if self.user:
62
+            return f"{self.alias} ({self.user}@{self.hostname})"
63
+        return f"{self.alias} ({self.hostname})"
1564
 
1665
 
17
-class UploadRunnable(QRunnable):
18
-    """
19
-    QRunnable for performing SFTP upload in a thread pool.
20
-    Emits finish or error signals on completion.
21
-    """
22
-    def __init__(self, backend, local_path, remote_path, finish_signal, error_signal):
66
+class WorkerSignals(QObject):
67
+    """Signals for thread workers"""
68
+
69
+    finished = pyqtSignal()
70
+    error = pyqtSignal(str)
71
+    progress = pyqtSignal(int)
72
+    result = pyqtSignal(object)
73
+
74
+
75
+class TransferWorker(QRunnable):
76
+    """Worker for file transfers"""
77
+
78
+    def __init__(self, sftp, source, dest, is_upload=True):
2379
         super().__init__()
24
-        self.backend = backend
25
-        self.local_path = local_path
26
-        self.remote_path = remote_path
27
-        self.finish_signal = finish_signal
28
-        self.error_signal = error_signal
80
+        self.sftp = sftp
81
+        self.source = source
82
+        self.dest = dest
83
+        self.is_upload = is_upload
84
+        self.signals = WorkerSignals()
2985
 
3086
     def run(self):
3187
         try:
32
-            self.backend.upload(self.local_path, self.remote_path)
33
-            self.finish_signal.emit()
88
+            if self.is_upload:
89
+                self.sftp.put(self.source, self.dest, callback=self._progress_callback)
90
+            else:
91
+                self.sftp.get(self.source, self.dest, callback=self._progress_callback)
92
+            self.signals.finished.emit()
3493
         except Exception as e:
35
-            self.error_signal.emit(str(e))
94
+            self.signals.error.emit(str(e))
3695
 
37
-
38
-# fetch default host/port from .env
39
-load_dotenv()
40
-DEFAULT_HOST = os.getenv('WULFTP_HOST', '')
41
-DEFAULT_PORT = int(os.getenv('WULFTP_PORT', '22'))
96
+    def _progress_callback(self, transferred, total):
97
+        if total > 0:
98
+            progress = int((transferred / total) * 100)
99
+            self.signals.progress.emit(progress)
42100
 
43101
 
44102
 class WulFTPClient(QMainWindow):
45
-    # signals for thread-safe UI updates
46
-    remote_refreshed = pyqtSignal()
47
-    upload_error = pyqtSignal(str)
48
-
49103
     def __init__(self):
50104
         super().__init__()
51
-        self.backend = SFTPBackend()
105
+        self.ssh = None
52106
         self.sftp = None
107
+        self.threadpool = QThreadPool()
108
+        self.ssh_hosts = self._load_ssh_config()
109
+        
110
+        # Import the model here to avoid circular imports
111
+        from .remote_model import RemoteFileSystemModel
112
+        self.remote_model = RemoteFileSystemModel()
113
+        
53114
         self.init_ui()
54115
 
55
-        # setup thread pool and connect signals
56
-        self.threadpool = QThreadPool()
57
-        self.remote_refreshed.connect(self.refresh_remote)
58
-        self.upload_error.connect(self.show_error)
116
+    def _load_ssh_config(self) -> Dict[str, SSHHost]:
117
+        """Load SSH hosts from ~/.ssh/config"""
118
+        hosts = {}
119
+        config_path = Path.home() / ".ssh" / "config"
120
+
121
+        if config_path.exists():
122
+            config = SSHConfig()
123
+            with open(config_path) as f:
124
+                config.parse(f)
125
+
126
+            for host in config.get_hostnames():
127
+                if host == "*":
128
+                    continue
129
+
130
+                cfg = config.lookup(host)
131
+                hosts[host] = SSHHost(
132
+                    alias=host,
133
+                    hostname=cfg.get("hostname", host),
134
+                    port=int(cfg.get("port", 22)),
135
+                    user=cfg.get("user"),
136
+                    key_file=cfg.get("identityfile", [None])[0],
137
+                )
138
+
139
+        # Add custom hosts from environment
140
+        if os.getenv("WULFTP_HOST"):
141
+            hosts["env_default"] = SSHHost(
142
+                alias="Default",
143
+                hostname=os.getenv("WULFTP_HOST"),
144
+                port=int(os.getenv("WULFTP_PORT", "22")),
145
+                user=os.getenv("WULFTP_USER"),
146
+                key_file=os.getenv("WULFTP_KEY"),
147
+            )
148
+
149
+        return hosts
59150
 
60151
     def init_ui(self):
61
-        """build and arrange ui elements."""
62
-        self.setWindowTitle('wulFTP POC')
63
-        self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowStaysOnTopHint)
64
-        self.setAcceptDrops(False)
152
+        self.setWindowTitle("wulFTP - Family Backup Tool")
153
+        self.setGeometry(100, 100, 1200, 800)
154
+
155
+        # Central widget
65156
         central = QWidget()
66157
         self.setCentralWidget(central)
67158
 
68
-        main_layout = QVBoxLayout()
69
-        main_layout.setContentsMargins(8, 8, 8, 8)
70
-        main_layout.setSpacing(8)
71
-
72
-        # form for connection fields
73
-        form_layout = QFormLayout()
74
-        form_layout.setHorizontalSpacing(8)
75
-        form_layout.setVerticalSpacing(4)
76
-
77
-        # connection fields with defaults from env
78
-        self.host_input = QLineEdit(DEFAULT_HOST)
79
-        self.port_input = QLineEdit(str(DEFAULT_PORT))
80
-        self.user_input = QLineEdit(os.getenv('WULFTP_USER', 'username'))
81
-        self.key_input = QLineEdit(os.getenv('WULFTP_KEY', os.path.expanduser('~/.ssh/id_rsa')))
82
-
83
-        # all just for this ico button
84
-        key_browse_btn = QToolButton()
85
-        icon = qta.icon('fa6s.folder-open', color='#ADC8FF')
86
-        key_browse_btn.setIcon(icon)
87
-        key_browse_btn.setIconSize(QSize(16, 16))
88
-        key_browse_btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
89
-        key_browse_btn.setAutoRaise(False)
90
-        key_browse_btn.setFixedSize(24, 24)
91
-        key_browse_btn.clicked.connect(self.browse_key)
92
-
93
-        # for opt passphrase
94
-        self.passphrase_input = QLineEdit()
95
-        self.passphrase_input.setEchoMode(QLineEdit.EchoMode.Password)
96
-
97
-        connect_btn = QPushButton('connect')
98
-        connect_btn.clicked.connect(self.connect_sftp)
99
-
100
-        # form layout for connection fields; needs work
101
-        form_layout.addRow('Host:', self.host_input)
102
-        form_layout.addRow('Port:', self.port_input)
103
-        form_layout.addRow('User:', self.user_input)
104
-
105
-        # right group as a FormLayout
106
-        right_form = QFormLayout()
107
-        right_form.setHorizontalSpacing(8)
108
-        right_form.setVerticalSpacing(4)
109
-        right_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
110
-
111
-        # key input with browse ico btn
112
-        key_row = QWidget()
113
-        key_layout = QHBoxLayout(key_row)
114
-        key_layout.setContentsMargins(0, 0, 0, 0)
115
-        key_layout.setSpacing(4)
116
-        key_layout.addWidget(self.key_input)
117
-        key_layout.addWidget(key_browse_btn)
118
-        right_form.addRow('Key:', key_row)
119
-
120
-        # passphrase row
121
-        pass_widget = QWidget()
122
-        pass_layout = QHBoxLayout(pass_widget)
123
-        pass_layout.setContentsMargins(0, 0, 0, 0)
124
-        pass_layout.addWidget(self.passphrase_input)
125
-        right_form.addRow('Passphrase:', pass_widget)
126
-
127
-        self.status_icon = QLabel()
128
-        self.status_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
129
-
130
-        # row for `connect` button/status ico
131
-        connect_widget = QWidget()
132
-        conn_layout = QHBoxLayout(connect_widget)
133
-        conn_layout.setAlignment(Qt.AlignmentFlag.AlignBaseline)
134
-        conn_layout.addWidget(connect_btn)
135
-        conn_layout.addWidget(self.status_icon)
136
-        right_form.addRow('', connect_widget)
137
-
138
-        # combine connection groups/forms side-by-side
139
-        groups_layout = QHBoxLayout()
140
-        groups_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
141
-        groups_layout.setSpacing(12)
142
-        groups_layout.addLayout(form_layout, 1)
143
-        groups_layout.addLayout(right_form, 1)
144
-        main_layout.addLayout(groups_layout)
145
-
146
-        # for the remote preview etc
147
-        remote_layout = QVBoxLayout()
148
-
149
-        self.browse_file_btn = QPushButton('browse…')
150
-        self.browse_file_btn.clicked.connect(self.browse_and_upload)
151
-        self.browse_file_btn.setEnabled(False)
152
-        remote_layout.addWidget(self.browse_file_btn)
153
-
154
-        # this needs rethinking?
155
-        self.remote_list = QListWidget()
156
-        self.remote_list.setEnabled(False)
157
-        remote_layout.addWidget(self.remote_list)
158
-        main_layout.addLayout(remote_layout)
159
-
160
-        central.setLayout(main_layout)
161
-
162
-        self.update_status_icon(False)
163
-
164
-    def update_status_icon(self, connected: bool):
165
-        """display the connected/disconnected icon based on state."""
166
-        if connected:
167
-            icon = qta.icon('fa6s.link', color='#62FF8B')
159
+        # Main layout
160
+        layout = QVBoxLayout(central)
161
+        layout.setContentsMargins(0, 0, 0, 0)
162
+
163
+        # Status bar (create early so conn_status is available)
164
+        self.status_bar = QStatusBar()
165
+        self.setStatusBar(self.status_bar)
166
+
167
+        # Progress bar (hidden by default)
168
+        self.progress_bar = QProgressBar()
169
+        self.progress_bar.setVisible(False)
170
+        self.status_bar.addPermanentWidget(self.progress_bar)
171
+
172
+        # Connection status
173
+        self.conn_status = QLabel("Disconnected")
174
+        self.status_bar.addPermanentWidget(self.conn_status)
175
+
176
+        # Create toolbar
177
+        self._create_toolbar()
178
+
179
+        # Connection bar
180
+        conn_widget = self._create_connection_bar()
181
+        layout.addWidget(conn_widget)
182
+
183
+        # Create splitter for two-pane view
184
+        splitter = QSplitter(Qt.Orientation.Horizontal)
185
+
186
+        # Local pane
187
+        self.local_pane = self._create_file_pane("Local Files", is_local=True)
188
+        splitter.addWidget(self.local_pane)
189
+
190
+        # Remote pane
191
+        self.remote_pane = self._create_file_pane("Remote Files", is_local=False)
192
+        self.remote_pane.setEnabled(False)
193
+        splitter.addWidget(self.remote_pane)
194
+
195
+        # Set initial splitter sizes
196
+        splitter.setSizes([600, 600])
197
+        layout.addWidget(splitter)
198
+
199
+    def _create_toolbar(self):
200
+        """Create main toolbar"""
201
+        toolbar = QToolBar()
202
+        self.addToolBar(toolbar)
203
+
204
+        # Upload action
205
+        upload_action = QAction(
206
+            qta.icon("fa5s.upload", color="#4CAF50"), "Upload Selected", self
207
+        )
208
+        upload_action.triggered.connect(self.upload_selected)
209
+        upload_action.setEnabled(False)
210
+        toolbar.addAction(upload_action)
211
+        self.upload_action = upload_action
212
+
213
+        # Download action
214
+        download_action = QAction(
215
+            qta.icon("fa5s.download", color="#2196F3"), "Download Selected", self
216
+        )
217
+        download_action.triggered.connect(self.download_selected)
218
+        download_action.setEnabled(False)
219
+        toolbar.addAction(download_action)
220
+        self.download_action = download_action
221
+
222
+        toolbar.addSeparator()
223
+
224
+        # Refresh action
225
+        refresh_action = QAction(
226
+            qta.icon("fa5s.sync", color="#FF9800"), "Refresh", self
227
+        )
228
+        refresh_action.triggered.connect(self.refresh_views)
229
+        toolbar.addAction(refresh_action)
230
+        
231
+        toolbar.addSeparator()
232
+        
233
+        # Settings actions
234
+        self.show_hidden_action = QAction("Show Hidden Files", self)
235
+        self.show_hidden_action.setCheckable(True)
236
+        self.show_hidden_action.setChecked(False)
237
+        self.show_hidden_action.toggled.connect(self._toggle_hidden_files)
238
+        toolbar.addAction(self.show_hidden_action)
239
+        
240
+        self.show_details_action = QAction("Show Full Details", self)
241
+        self.show_details_action.setCheckable(True)
242
+        self.show_details_action.setChecked(False)
243
+        self.show_details_action.toggled.connect(self._toggle_full_details)
244
+        toolbar.addAction(self.show_details_action)
245
+
246
+    def _create_connection_bar(self):
247
+        """Create connection controls"""
248
+        widget = QWidget()
249
+        layout = QHBoxLayout(widget)
250
+        layout.setContentsMargins(8, 4, 8, 4)
251
+
252
+        # Host dropdown
253
+        layout.addWidget(QLabel("Host:"))
254
+        self.host_combo = QComboBox()
255
+        self.host_combo.setMinimumWidth(250)
256
+
257
+        # Populate hosts
258
+        for host in self.ssh_hosts.values():
259
+            self.host_combo.addItem(host.display_name(), host)
260
+
261
+        # Add custom option
262
+        self.host_combo.addItem("Custom...", None)
263
+        self.host_combo.currentIndexChanged.connect(self._on_host_changed)
264
+        layout.addWidget(self.host_combo)
265
+
266
+        # Custom host fields (hidden by default)
267
+        self.custom_host = QLineEdit()
268
+        self.custom_host.setPlaceholderText("hostname")
269
+        self.custom_host.setVisible(False)
270
+        layout.addWidget(self.custom_host)
271
+
272
+        self.custom_user = QLineEdit()
273
+        self.custom_user.setPlaceholderText("username")
274
+        self.custom_user.setVisible(False)
275
+        layout.addWidget(self.custom_user)
276
+
277
+        # Connect button
278
+        self.connect_btn = QPushButton("Connect")
279
+        self.connect_btn.clicked.connect(self.toggle_connection)
280
+        layout.addWidget(self.connect_btn)
281
+
282
+        # Connection indicator
283
+        self.conn_indicator = QLabel()
284
+        self._update_connection_status(False)
285
+        layout.addWidget(self.conn_indicator)
286
+
287
+        layout.addStretch()
288
+        return widget
289
+
290
+    def _create_file_pane(self, title: str, is_local: bool):
291
+        """Create a file browser pane"""
292
+        widget = QWidget()
293
+        layout = QVBoxLayout(widget)
294
+
295
+        # Header with navigation
296
+        header = QWidget()
297
+        header_layout = QHBoxLayout(header)
298
+        header_layout.setContentsMargins(0, 0, 0, 0)
299
+
300
+        # Title
301
+        title_label = QLabel(title)
302
+        title_label.setStyleSheet("font-weight: bold;")
303
+        header_layout.addWidget(title_label)
304
+
305
+        header_layout.addStretch()
306
+
307
+        # Navigation buttons
308
+        up_btn = QPushButton()
309
+        up_btn.setIcon(qta.icon("fa5s.arrow-up"))
310
+        up_btn.setMaximumSize(30, 30)
311
+        header_layout.addWidget(up_btn)
312
+
313
+        home_btn = QPushButton()
314
+        home_btn.setIcon(qta.icon("fa5s.home"))
315
+        home_btn.setMaximumSize(30, 30)
316
+        header_layout.addWidget(home_btn)
317
+
318
+        layout.addWidget(header)
319
+
320
+        # Path display
321
+        path_edit = QLineEdit()
322
+        path_edit.setReadOnly(True)
323
+        layout.addWidget(path_edit)
324
+
325
+        # File view
326
+        if is_local:
327
+            # Local file system model
328
+            model = QFileSystemModel()
329
+            model.setRootPath(QDir.homePath())
330
+
331
+            view = QTreeView()
332
+            view.setModel(model)
333
+            view.setRootIndex(model.index(QDir.homePath()))
334
+            view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
335
+
336
+            # Configure columns
337
+            view.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
338
+            view.setColumnWidth(1, 100)  # Size
339
+            view.setColumnWidth(2, 120)  # Type
340
+            view.setColumnWidth(3, 120)  # Date
341
+
342
+            # Store references
343
+            self.local_model = model
344
+            self.local_view = view
345
+            self.local_path = path_edit
346
+            self.local_path.setText(QDir.homePath())
347
+
348
+            # Connect navigation
349
+            up_btn.clicked.connect(lambda: self._navigate_up(True))
350
+            home_btn.clicked.connect(lambda: self._navigate_home(True))
351
+            view.doubleClicked.connect(lambda idx: self._on_local_double_click(idx))
352
+
168353
         else:
169
-            icon = qta.icon('fa6s.link-slash', color='#FF595B')
170
-
171
-        self.status_icon.setPixmap(icon.pixmap(24, 24))
172
-
173
-    def dragEnterEvent(self, event):
174
-        """allow drag-and-drop upload if the event carries file URLs."""
175
-        if event.mimeData().hasUrls():
176
-            event.acceptProposedAction()
177
-
178
-    def dropEvent(self, event):
179
-        """handle dropped files by uploading each to the remote server."""
180
-        for url in event.mimeData().urls():
181
-            local_path = url.toLocalFile()
182
-            filename = os.path.basename(local_path)
183
-            remote_file = os.path.join(self.remote_path, filename)
184
-            # schedule upload in thread pool
185
-            runnable = UploadRunnable(
186
-                self.backend,
187
-                local_path,
188
-                remote_file,
189
-                self.remote_refreshed,
190
-                self.upload_error
191
-            )
192
-            self.threadpool.start(runnable)
193
-        event.acceptProposedAction()
354
+            # Remote file list with the new model
355
+            view = QTreeView()
356
+            view.setModel(self.remote_model)
357
+            view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
358
+            
359
+            # Configure columns
360
+            view.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
361
+            view.setColumnWidth(1, 100)  # Size
362
+            view.setColumnWidth(2, 100)  # Type
363
+            view.setColumnWidth(3, 120)  # Modified
364
+            
365
+            # Performance optimizations
366
+            view.setUniformRowHeights(True)
367
+            view.setAlternatingRowColors(True)
368
+            
369
+            # Store references
370
+            self.remote_view = view
371
+            self.remote_path = path_edit
372
+            self.remote_cwd = "/"
373
+            
374
+            # Connect model signals
375
+            self.remote_model.directoryLoaded.connect(lambda path: self.remote_path.setText(path))
376
+            self.remote_model.errorOccurred.connect(lambda err: QMessageBox.warning(self, "Error", err))
377
+            
378
+            # Connect navigation
379
+            up_btn.clicked.connect(lambda: self._navigate_up(False))
380
+            home_btn.clicked.connect(lambda: self._navigate_home(False))
381
+            view.doubleClicked.connect(lambda idx: self._on_remote_double_click(idx))
382
+            
383
+            # Connect single click to update path
384
+            view.clicked.connect(lambda idx: self._on_remote_clicked(idx))
385
+
386
+        # Context menu
387
+        view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
388
+        view.customContextMenuRequested.connect(
389
+            lambda pos: self._show_context_menu(pos, is_local)
390
+        )
194391
 
195
-    def browse_key(self):
196
-        """open a file dialog to select the SSH private key path."""
197
-        path, _ = QFileDialog.getOpenFileName(self, 'Select Private Key')
198
-        if not path:
199
-            return
200
-        self.key_input.setText(path)
392
+        layout.addWidget(view)
393
+        return widget
201394
 
202
-    def show_error(self, msg):
203
-        """show an error dialog with the given message."""
204
-        QMessageBox.critical(self, 'Error', msg)
395
+    def _on_host_changed(self, index):
396
+        """Handle host selection change"""
397
+        host = self.host_combo.currentData()
398
+        is_custom = host is None
205399
 
206
-    def connect_sftp(self):
207
-        """connect to the SFTP server using current form values and update UI."""
208
-        host = self.host_input.text()
209
-        port = int(self.port_input.text())
210
-        user = self.user_input.text()
211
-        key_path = self.key_input.text()
212
-        passphrase = self.passphrase_input.text() or None
400
+        self.custom_host.setVisible(is_custom)
401
+        self.custom_user.setVisible(is_custom)
402
+
403
+    def _update_connection_status(self, connected: bool):
404
+        """Update connection indicators"""
405
+        if connected:
406
+            icon = qta.icon("fa5s.link", color="#4CAF50")
407
+            self.conn_status.setText("Connected")
408
+            self.connect_btn.setText("Disconnect")
409
+        else:
410
+            icon = qta.icon("fa5s.unlink", color="#F44336")
411
+            self.conn_status.setText("Disconnected")
412
+            self.connect_btn.setText("Connect")
413
+
414
+        self.conn_indicator.setPixmap(icon.pixmap(24, 24))
415
+        
416
+        # Only update these if they exist (they might not during initialization)
417
+        if hasattr(self, 'upload_action'):
418
+            self.upload_action.setEnabled(connected)
419
+        if hasattr(self, 'download_action'):
420
+            self.download_action.setEnabled(connected)
421
+        if hasattr(self, 'remote_pane'):
422
+            self.remote_pane.setEnabled(connected)
423
+
424
+    def toggle_connection(self):
425
+        """Connect or disconnect from server"""
426
+        if self.sftp:
427
+            self.disconnect()
428
+        else:
429
+            self.connect()
213430
 
431
+    def connect(self):
432
+        """Establish SFTP connection"""
214433
         try:
215
-            self.backend.connect(host, port, user, key_path, passphrase)
216
-            self.sftp = self.backend.sftp
217
-
218
-            self.update_status_icon(True)
219
-            self.browse_file_btn.setEnabled(True)
220
-            self.remote_list.setEnabled(True)
221
-            self.setAcceptDrops(True)
222
-            self.refresh_remote()
434
+            host_data = self.host_combo.currentData()
435
+
436
+            if host_data:
437
+                # Use predefined host
438
+                hostname = host_data.hostname
439
+                port = host_data.port
440
+                username = host_data.user
441
+                key_file = host_data.key_file
442
+            else:
443
+                # Use custom values
444
+                hostname = self.custom_host.text()
445
+                username = self.custom_user.text()
446
+                port = 22
447
+                key_file = None
448
+
449
+            # Create SSH client
450
+            self.ssh = paramiko.SSHClient()
451
+            self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
452
+
453
+            # Try to connect with various authentication methods
454
+            connect_kwargs = {
455
+                "hostname": hostname,
456
+                "port": port,
457
+                "username": username,
458
+                "timeout": 10,
459
+            }
460
+
461
+            if key_file and os.path.exists(os.path.expanduser(key_file)):
462
+                # Try different key types
463
+                key_path = os.path.expanduser(key_file)
464
+                try:
465
+                    # Try to load key with auto-detection
466
+                    connect_kwargs["key_filename"] = key_path
467
+                except:
468
+                    pass
469
+
470
+            # Allow password fallback
471
+            connect_kwargs["look_for_keys"] = True
472
+            connect_kwargs["allow_agent"] = True
473
+
474
+            self.ssh.connect(**connect_kwargs)
475
+            self.sftp = self.ssh.open_sftp()
476
+
477
+            # Update remote model with SFTP connection
478
+            self.remote_model.set_sftp(self.sftp)
479
+            
480
+            # Update UI
481
+            self._update_connection_status(True)
482
+
483
+            self.status_bar.showMessage(f"Connected to {hostname}", 3000)
484
+
223485
         except Exception as e:
224
-            self.show_error(str(e))
486
+            QMessageBox.critical(self, "Connection Error", str(e))
487
+            self._update_connection_status(False)
488
+
489
+    def disconnect(self):
490
+        """Close SFTP connection"""
491
+        if self.sftp:
492
+            self.sftp.close()
493
+            self.sftp = None
494
+        if self.ssh:
495
+            self.ssh.close()
496
+            self.ssh = None
497
+
498
+        # Clear remote model
499
+        self.remote_model.set_sftp(None)
500
+        
501
+        self._update_connection_status(False)
502
+        self.status_bar.showMessage("Disconnected", 3000)
503
+
504
+    def refresh_views(self):
505
+        """Refresh both file views"""
506
+        # Local is automatically updated by QFileSystemModel
507
+        if self.sftp and self.remote_model:
508
+            self.remote_model.refresh()
509
+
510
+    def _toggle_hidden_files(self, checked: bool):
511
+        """Toggle showing hidden files in remote view"""
512
+        self.remote_model.set_show_hidden(checked)
513
+        
514
+    def _toggle_full_details(self, checked: bool):
515
+        """Toggle showing full details (permissions) in remote view"""
516
+        self.remote_model.set_show_full_details(checked)
517
+        # Adjust column visibility
518
+        if checked:
519
+            self.remote_view.showColumn(4)  # Permissions column
520
+        else:
521
+            self.remote_view.hideColumn(4)
522
+
523
+    def _navigate_up(self, is_local: bool):
524
+        """Navigate up one directory"""
525
+        if is_local:
526
+            current = self.local_view.rootIndex()
527
+            parent = self.local_model.parent(current)
528
+            if parent.isValid():
529
+                self.local_view.setRootIndex(parent)
530
+                self.local_path.setText(self.local_model.filePath(parent))
531
+        else:
532
+            # For remote, we navigate in the tree view
533
+            current_path = self.remote_path.text()
534
+            if current_path and current_path != "/":
535
+                parent_path = os.path.dirname(current_path)
536
+                # Find the parent item in the model
537
+                parent_item = self.remote_model._find_item_by_path(parent_path)
538
+                if parent_item:
539
+                    parent_idx = self.remote_model._get_index_for_item(parent_item)
540
+                    # Expand to show it
541
+                    self.remote_view.expand(parent_idx)
542
+                    # Update path display
543
+                    self.remote_path.setText(parent_path)
544
+
545
+    def _navigate_home(self, is_local: bool):
546
+        """Navigate to home directory"""
547
+        if is_local:
548
+            home_index = self.local_model.index(QDir.homePath())
549
+            self.local_view.setRootIndex(home_index)
550
+            self.local_path.setText(QDir.homePath())
551
+        else:
552
+            # Just update the path display for remote
553
+            self.remote_path.setText("/")
554
+
555
+    def _on_remote_double_click(self, index: QModelIndex):
556
+        """Handle double-click on remote file"""
557
+        if self.remote_model.isDir(index):
558
+            # Don't change the view root, just expand/collapse
559
+            if self.remote_view.isExpanded(index):
560
+                self.remote_view.collapse(index)
561
+            else:
562
+                self.remote_view.expand(index)
563
+            
564
+            # Update path display
565
+            path = self.remote_model.filePath(index)
566
+            self.remote_path.setText(path)
567
+
568
+    def _on_remote_clicked(self, index: QModelIndex):
569
+        """Handle single click on remote file"""
570
+        # Update path display when item is selected
571
+        path = self.remote_model.filePath(index)
572
+        if path:
573
+            self.remote_path.setText(path)
574
+
575
+    def _on_local_double_click(self, index: QModelIndex):
576
+        """Handle double-click on local file"""
577
+        if self.local_model.isDir(index):
578
+            self.local_view.setRootIndex(index)
579
+            self.local_path.setText(self.local_model.filePath(index))
580
+
581
+    def _show_context_menu(self, pos, is_local: bool):
582
+        """Show context menu for file operations"""
583
+        menu = QMenu()
584
+
585
+        if self.sftp:  # Only show transfer options when connected
586
+            if is_local:
587
+                upload_action = menu.addAction(
588
+                    qta.icon("fa5s.upload", color="#4CAF50"), "Upload"
589
+                )
590
+                upload_action.triggered.connect(self.upload_selected)
591
+            else:
592
+                download_action = menu.addAction(
593
+                    qta.icon("fa5s.download", color="#2196F3"), "Download"
594
+                )
595
+                download_action.triggered.connect(self.download_selected)
596
+
597
+        menu.addSeparator()
598
+
599
+        refresh_action = menu.addAction(qta.icon("fa5s.sync"), "Refresh")
600
+        refresh_action.triggered.connect(self.refresh_views)
601
+
602
+        # Show menu at cursor position
603
+        view = self.local_view if is_local else self.remote_view
604
+        menu.exec(view.mapToGlobal(pos))
605
+
606
+    def upload_selected(self):
607
+        """Upload selected local files"""
608
+        if not self.sftp:
609
+            return
225610
 
226
-    def refresh_remote(self):
227
-        """refresh the remote file list for the current remote directory."""
228
-        if self.sftp is None:
611
+        selection = self.local_view.selectedIndexes()
612
+        if not selection:
229613
             return
230614
 
231
-        try:
232
-            self.remote_path = '.'
233
-            self.remote_list.clear()
234
-            for f in self.backend.listdir(self.remote_path):
235
-                self.remote_list.addItem(f)
236
-        except Exception as e:
237
-            self.show_error(str(e))
615
+        # Get unique file paths (avoiding duplicates from multiple columns)
616
+        files = set()
617
+        for index in selection:
618
+            if index.column() == 0:  # Only process name column
619
+                path = self.local_model.filePath(index)
620
+                files.add(path)
621
+
622
+        self._transfer_files(list(files), is_upload=True)
238623
 
239
-    def browse_and_upload(self):
240
-        """open file dialog and upload the selected file to remote."""
241
-        path, _ = QFileDialog.getOpenFileName(self, 'select file to upload')
242
-        if not path:
624
+    def download_selected(self):
625
+        """Download selected remote files"""
626
+        if not self.sftp:
243627
             return
244
-        filename = os.path.basename(path)
245
-        remote_file = os.path.join(self.remote_path, filename)
246
-
247
-        # schedule file upload in thread pool
248
-        runnable = UploadRunnable(
249
-            self.backend,
250
-            path,
251
-            remote_file,
252
-            self.remote_refreshed,
253
-            self.upload_error
254
-        )
255
-        self.threadpool.start(runnable)
628
+
629
+        selection = self.remote_view.selectedIndexes()
630
+        if not selection:
631
+            return
632
+
633
+        # Get unique file paths (avoiding duplicates from multiple columns)
634
+        files = []
635
+        for index in selection:
636
+            if index.column() == 0:  # Only process name column
637
+                file_info = self.remote_model.fileInfo(index)
638
+                if file_info and not file_info.is_dir:
639
+                    files.append(file_info.full_path)
640
+
641
+        if files:
642
+            self._transfer_files(files, is_upload=False)
643
+        else:
644
+            QMessageBox.information(self, "Download", "Please select files to download (directories not supported yet)")
645
+
646
+    def _transfer_files(self, files: List[str], is_upload: bool):
647
+        """Transfer files with progress tracking"""
648
+        for file_path in files:
649
+            filename = os.path.basename(file_path)
650
+
651
+            if is_upload:
652
+                # Get current remote directory from path display
653
+                remote_base = self.remote_path.text() or "/"
654
+                remote_path = os.path.join(remote_base, filename)
655
+                worker = TransferWorker(self.sftp, file_path, remote_path, True)
656
+            else:
657
+                # Download to current local directory
658
+                local_base = self.local_model.filePath(self.local_view.rootIndex())
659
+                local_path = os.path.join(local_base, filename)
660
+                worker = TransferWorker(self.sftp, file_path, local_path, False)
661
+
662
+            # Connect signals
663
+            worker.signals.progress.connect(self._update_progress)
664
+            worker.signals.finished.connect(self._transfer_complete)
665
+            worker.signals.error.connect(self._transfer_error)
666
+
667
+            # Show progress bar
668
+            self.progress_bar.setVisible(True)
669
+            self.progress_bar.setValue(0)
670
+
671
+            # Start transfer
672
+            self.threadpool.start(worker)
673
+
674
+    @pyqtSlot(int)
675
+    def _update_progress(self, value):
676
+        """Update progress bar"""
677
+        self.progress_bar.setValue(value)
678
+
679
+    @pyqtSlot()
680
+    def _transfer_complete(self):
681
+        """Handle transfer completion"""
682
+        self.progress_bar.setVisible(False)
683
+        if hasattr(self, 'remote_model') and self.remote_model:
684
+            self.remote_model.refresh()
685
+        self.status_bar.showMessage("Transfer complete", 3000)
686
+
687
+    @pyqtSlot(str)
688
+    def _transfer_error(self, error):
689
+        """Handle transfer error"""
690
+        self.progress_bar.setVisible(False)
691
+        QMessageBox.critical(self, "Transfer Error", error)
256692
 
257693
 
258694
 def main():
259
-    """driver for wulftp."""
695
+    """Application entry point"""
260696
     app = QApplication(sys.argv)
261
-    w = WulFTPClient()
262
-    w.show()
697
+    app.setApplicationName("wulFTP")
698
+
699
+    # Set application style
700
+    app.setStyle("Fusion")
701
+
702
+    window = WulFTPClient()
703
+    window.show()
704
+
263705
     sys.exit(app.exec())
264706
 
265707
 
266
-if __name__ == '__main__':
267
-    main()
708
+if __name__ == "__main__":
709
+    main()