tenseleyflow/wulftp / 7ad6d3f

Browse files

refactor, focus issues fixed?, stable I think

Authored by espadonne
SHA
7ad6d3f464543ccd5f1fc0a68af294517a65352f
Parents
24cbe9c
Tree
8e1cf94

16 changed files

StatusFile+-
M src/wulftp/__init__.py 6 2
A src/wulftp/core/__init__.py 27 0
A src/wulftp/core/constants.py 166 0
A src/wulftp/core/file_transfer.py 181 0
A src/wulftp/core/ssh_manager.py 65 0
A src/wulftp/main.py 30 0
A src/wulftp/models/__init__.py 13 0
R src/wulftp/remote_model.pysrc/wulftp/models/remote_model.py 0 0
D src/wulftp/sftp_backend.py 0 61
A src/wulftp/ui/__init__.py 23 0
A src/wulftp/ui/components.py 566 0
A src/wulftp/ui/main_window.py 730 0
A src/wulftp/ui/widgets.py 81 0
A src/wulftp/utils/__init__.py 25 0
A src/wulftp/utils/helpers.py 326 0
D src/wulftp/wulftp.py 0 1467
src/wulftp/__init__.pymodified
@@ -1,5 +1,9 @@
11
 """
2
-package entry point for wulftp.
2
+wulFTP - A modern SFTP client with PyQt6.
33
 """
44
 
5
-from .wulftp import main
5
+from .main import main
6
+
7
+__version__ = "0.1.0"
8
+__author__ = "mfw"
9
+__all__ = ["main"]
src/wulftp/core/__init__.pyadded
@@ -0,0 +1,27 @@
1
+"""Core functionality for wulFTP."""
2
+
3
+from .constants import Icons, FileTypes, AppConfig, Styles, Permissions
4
+from .file_transfer import (
5
+    FileTransferManager,
6
+    TransferWorker,
7
+    DirectoryTransferWorker,
8
+    WorkerSignals
9
+)
10
+from .ssh_manager import SSHHost, load_ssh_config
11
+
12
+__all__ = [
13
+    # Constants
14
+    "Icons", 
15
+    "FileTypes", 
16
+    "AppConfig", 
17
+    "Styles",
18
+    "Permissions",
19
+    # File transfer
20
+    "FileTransferManager",
21
+    "TransferWorker",
22
+    "DirectoryTransferWorker",
23
+    "WorkerSignals",
24
+    # SSH management
25
+    "SSHHost", 
26
+    "load_ssh_config"
27
+]
src/wulftp/core/constants.pyadded
@@ -0,0 +1,166 @@
1
+"""Constants and configuration for wulFTP."""
2
+
3
+from dataclasses import dataclass
4
+from typing import Tuple, List
5
+
6
+
7
+@dataclass
8
+class IconConfig:
9
+    """Icon configuration with name and color"""
10
+    name: str
11
+    color: str = "#000000"
12
+    
13
+    def to_tuple(self) -> Tuple[str, dict]:
14
+        """Convert to tuple for qtawesome"""
15
+        return (self.name, {"color": self.color})
16
+
17
+
18
+class Icons:
19
+    """Application icons"""
20
+    # Connection
21
+    CONNECT = IconConfig("fa5s.plug", "#4CAF50")
22
+    DISCONNECT = IconConfig("fa5s.unlink", "#F44336")
23
+    CONNECTED = IconConfig("fa5s.link", "#4CAF50")
24
+    DISCONNECTED = IconConfig("fa5s.unlink", "#F44336")
25
+    
26
+    # File operations
27
+    UPLOAD = IconConfig("fa5s.upload", "#4CAF50")
28
+    DOWNLOAD = IconConfig("fa5s.download", "#2196F3")
29
+    DELETE = IconConfig("fa5s.trash", "#F44336")
30
+    DELETE_DISABLED = IconConfig("fa5s.trash", "#888888")
31
+    REFRESH = IconConfig("fa5s.sync", "#FF9800")
32
+    
33
+    # Navigation
34
+    UP = IconConfig("fa5s.arrow-up")
35
+    HOME = IconConfig("fa5s.home")
36
+    FOLDER_NEW = IconConfig("fa5s.folder-plus", "#FF9800")
37
+    
38
+    # File types
39
+    FOLDER = IconConfig("fa5s.folder", "#FFD93D")
40
+    FOLDER_OPEN = IconConfig("fa5s.folder-open")
41
+    FILE = IconConfig("fa5s.file", "#6C757D")
42
+    FILE_TEXT = IconConfig("fa5s.file-alt", "#6C757D")
43
+    FILE_CODE = IconConfig("fa5s.file-code", "#28A745")
44
+    FILE_IMAGE = IconConfig("fa5s.file-image", "#17A2B8")
45
+    FILE_AUDIO = IconConfig("fa5s.file-audio", "#FD7E14")
46
+    FILE_VIDEO = IconConfig("fa5s.file-video", "#DC3545")
47
+    FILE_ARCHIVE = IconConfig("fa5s.file-archive", "#6F42C1")
48
+    FILE_PDF = IconConfig("fa5s.file-pdf", "#DC3545")
49
+    LINK = IconConfig("fa5s.link")
50
+
51
+
52
+class FileTypes:
53
+    """File type extensions grouped by category"""
54
+    TEXT = [".txt", ".md", ".log", ".rst", ".tex", ".rtf"]
55
+    CODE = [".py", ".js", ".cpp", ".c", ".java", ".cs", ".rb", ".go", ".rs", 
56
+            ".php", ".swift", ".kt", ".scala", ".r", ".m", ".h", ".hpp",
57
+            ".css", ".html", ".xml", ".json", ".yaml", ".yml", ".toml"]
58
+    IMAGE = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".ico", 
59
+             ".webp", ".tiff", ".tif", ".raw", ".heic"]
60
+    AUDIO = [".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".wma", 
61
+             ".opus", ".ape", ".mid", ".midi"]
62
+    VIDEO = [".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm", 
63
+             ".m4v", ".mpg", ".mpeg", ".3gp"]
64
+    ARCHIVE = [".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", 
65
+               ".tar.gz", ".tar.bz2", ".tar.xz", ".tgz"]
66
+    PDF = [".pdf"]
67
+    
68
+    @classmethod
69
+    def get_category(cls, extension: str) -> str:
70
+        """Get the category for a file extension"""
71
+        ext = extension.lower()
72
+        if ext in cls.TEXT:
73
+            return "text"
74
+        elif ext in cls.CODE:
75
+            return "code"
76
+        elif ext in cls.IMAGE:
77
+            return "image"
78
+        elif ext in cls.AUDIO:
79
+            return "audio"
80
+        elif ext in cls.VIDEO:
81
+            return "video"
82
+        elif ext in cls.ARCHIVE:
83
+            return "archive"
84
+        elif ext in cls.PDF:
85
+            return "pdf"
86
+        else:
87
+            return "file"
88
+
89
+
90
+class AppConfig:
91
+    """Application configuration"""
92
+    # Window settings
93
+    WINDOW_TITLE = "wulFTP - Family Backup Tool"
94
+    WINDOW_X = 100
95
+    WINDOW_Y = 100
96
+    WINDOW_WIDTH = 1200
97
+    WINDOW_HEIGHT = 800
98
+    
99
+    # UI settings
100
+    ICON_SIZE = 24
101
+    STATUS_MESSAGE_TIMEOUT = 3000
102
+    
103
+    # Pane settings
104
+    SPLITTER_SIZES = [600, 600]
105
+    
106
+    # Toolbar settings
107
+    HOST_COMBO_MIN_WIDTH = 250
108
+    HOST_COMBO_MAX_WIDTH = 350
109
+    CUSTOM_HOST_MAX_WIDTH = 150
110
+    CUSTOM_USER_MAX_WIDTH = 100
111
+    
112
+    # Navigation button size
113
+    NAV_BUTTON_SIZE = 30
114
+    
115
+    # Column widths
116
+    COLUMN_SIZE = 100
117
+    COLUMN_TYPE = 100
118
+    COLUMN_DATE = 120
119
+    
120
+    # Connection settings
121
+    CONNECTION_TIMEOUT = 10
122
+    
123
+    # UI Messages
124
+    MSG_CONNECTED = "Connected to {}"
125
+    MSG_DISCONNECTED = "Disconnected"
126
+    MSG_CONNECTING = "Connecting..."
127
+    MSG_UPLOAD_DIR = "Uploading directory: {}"
128
+    MSG_DOWNLOAD_DIR = "Downloading directory: {}"
129
+    MSG_TRANSFER_COMPLETE = "Transfer complete"
130
+    MSG_DELETE_COMPLETE = "Deleted {} item(s)"
131
+    MSG_CREATE_DIR = "Created directory: {}"
132
+    MSG_CREATE_REMOTE_DIR = "Created remote directory: {}"
133
+
134
+
135
+class Styles:
136
+    """UI Styles"""
137
+    PATH_EDIT = """
138
+        QLineEdit { 
139
+            background-color: #2b2b2b; 
140
+            color: #ffffff;
141
+            border: 1px solid #555555;
142
+            padding: 4px;
143
+            font-family: monospace;
144
+        }
145
+    """
146
+    
147
+    TITLE_LABEL = "font-weight: bold; font-size: 14px;"
148
+
149
+
150
+class Permissions:
151
+    """Unix permission bits"""
152
+    # File types
153
+    S_IFDIR = 0o040000  # directory
154
+    S_IFREG = 0o100000  # regular file
155
+    S_IFLNK = 0o120000  # symbolic link
156
+    
157
+    # Permissions
158
+    S_IRUSR = 0o400  # owner read
159
+    S_IWUSR = 0o200  # owner write
160
+    S_IXUSR = 0o100  # owner execute
161
+    S_IRGRP = 0o040  # group read
162
+    S_IWGRP = 0o020  # group write
163
+    S_IXGRP = 0o010  # group execute
164
+    S_IROTH = 0o004  # other read
165
+    S_IWOTH = 0o002  # other write
166
+    S_IXOTH = 0o001  # other execute
src/wulftp/core/file_transfer.pyadded
@@ -0,0 +1,181 @@
1
+"""File transfer operations for wulFTP."""
2
+
3
+import os
4
+import stat
5
+from typing import Optional
6
+
7
+from PyQt6.QtCore import QObject, QRunnable, pyqtSignal
8
+
9
+
10
+class WorkerSignals(QObject):
11
+    """Signals for thread workers"""
12
+
13
+    finished = pyqtSignal()
14
+    error = pyqtSignal(str)
15
+    progress = pyqtSignal(int)
16
+    result = pyqtSignal(object)
17
+
18
+
19
+class TransferWorker(QRunnable):
20
+    """Worker for file transfers"""
21
+
22
+    def __init__(self, sftp, source: str, dest: str, is_upload: bool = True):
23
+        super().__init__()
24
+        self.sftp = sftp
25
+        self.source = source
26
+        self.dest = dest
27
+        self.is_upload = is_upload
28
+        self.signals = WorkerSignals()
29
+
30
+    def run(self):
31
+        """Execute the transfer"""
32
+        try:
33
+            if self.is_upload:
34
+                self.sftp.put(self.source, self.dest, callback=self._progress_callback)
35
+            else:
36
+                self.sftp.get(self.source, self.dest, callback=self._progress_callback)
37
+            self.signals.finished.emit()
38
+        except Exception as e:
39
+            self.signals.error.emit(str(e))
40
+
41
+    def _progress_callback(self, transferred: int, total: int):
42
+        """Update progress during transfer"""
43
+        if total > 0:
44
+            progress = int((transferred / total) * 100)
45
+            self.signals.progress.emit(progress)
46
+
47
+
48
+class DirectoryTransferWorker(QRunnable):
49
+    """Worker for directory transfers"""
50
+    
51
+    def __init__(self, sftp, source_dir: str, dest_dir: str, is_upload: bool = True):
52
+        super().__init__()
53
+        self.sftp = sftp
54
+        self.source_dir = source_dir
55
+        self.dest_dir = dest_dir
56
+        self.is_upload = is_upload
57
+        self.signals = WorkerSignals()
58
+        self.total_files = 0
59
+        self.processed_files = 0
60
+        
61
+    def run(self):
62
+        """Execute the directory transfer"""
63
+        try:
64
+            if self.is_upload:
65
+                self._upload_directory(self.source_dir, self.dest_dir)
66
+            else:
67
+                self._download_directory(self.source_dir, self.dest_dir)
68
+            self.signals.finished.emit()
69
+        except Exception as e:
70
+            self.signals.error.emit(str(e))
71
+    
72
+    def _count_files(self, local_path: str) -> int:
73
+        """Count total files in a local directory recursively"""
74
+        count = 0
75
+        for root, dirs, files in os.walk(local_path):
76
+            count += len(files)
77
+        return count
78
+    
79
+    def _count_remote_files(self, remote_path: str) -> int:
80
+        """Count total files in a remote directory recursively"""
81
+        count = 0
82
+        try:
83
+            for item in self.sftp.listdir_attr(remote_path):
84
+                item_path = os.path.join(remote_path, item.filename)
85
+                if stat.S_ISDIR(item.st_mode):
86
+                    count += self._count_remote_files(item_path)
87
+                else:
88
+                    count += 1
89
+        except:
90
+            pass
91
+        return count
92
+    
93
+    def _upload_directory(self, local_path: str, remote_path: str):
94
+        """Recursively upload a directory"""
95
+        # Count total files first
96
+        if self.total_files == 0:
97
+            self.total_files = self._count_files(local_path)
98
+        
99
+        # Create remote directory
100
+        try:
101
+            self.sftp.mkdir(remote_path)
102
+        except:
103
+            # Directory might already exist
104
+            pass
105
+        
106
+        # Upload contents
107
+        for item in os.listdir(local_path):
108
+            local_item = os.path.join(local_path, item)
109
+            remote_item = os.path.join(remote_path, item).replace('\\', '/')
110
+            
111
+            if os.path.isdir(local_item):
112
+                self._upload_directory(local_item, remote_item)
113
+            else:
114
+                # Upload file
115
+                self.sftp.put(local_item, remote_item)
116
+                self.processed_files += 1
117
+                if self.total_files > 0:
118
+                    progress = int((self.processed_files / self.total_files) * 100)
119
+                    self.signals.progress.emit(progress)
120
+    
121
+    def _download_directory(self, remote_path: str, local_path: str):
122
+        """Recursively download a directory"""
123
+        # Count total files first
124
+        if self.total_files == 0:
125
+            self.total_files = self._count_remote_files(remote_path)
126
+        
127
+        # Create local directory
128
+        os.makedirs(local_path, exist_ok=True)
129
+        
130
+        # Download contents
131
+        for item in self.sftp.listdir_attr(remote_path):
132
+            remote_item = os.path.join(remote_path, item.filename).replace('\\', '/')
133
+            local_item = os.path.join(local_path, item.filename)
134
+            
135
+            if stat.S_ISDIR(item.st_mode):
136
+                self._download_directory(remote_item, local_item)
137
+            else:
138
+                # Download file
139
+                self.sftp.get(remote_item, local_item)
140
+                self.processed_files += 1
141
+                if self.total_files > 0:
142
+                    progress = int((self.processed_files / self.total_files) * 100)
143
+                    self.signals.progress.emit(progress)
144
+
145
+
146
+class FileTransferManager:
147
+    """Manages file transfer operations"""
148
+    
149
+    def __init__(self, sftp, threadpool):
150
+        self.sftp = sftp
151
+        self.threadpool = threadpool
152
+        
153
+    def transfer_file(self, source: str, dest: str, is_upload: bool,
154
+                     progress_callback=None, complete_callback=None, error_callback=None):
155
+        """Start a single file transfer"""
156
+        worker = TransferWorker(self.sftp, source, dest, is_upload)
157
+        
158
+        if progress_callback:
159
+            worker.signals.progress.connect(progress_callback)
160
+        if complete_callback:
161
+            worker.signals.finished.connect(complete_callback)
162
+        if error_callback:
163
+            worker.signals.error.connect(error_callback)
164
+            
165
+        self.threadpool.start(worker)
166
+        return worker
167
+    
168
+    def transfer_directory(self, source_dir: str, dest_dir: str, is_upload: bool,
169
+                          progress_callback=None, complete_callback=None, error_callback=None):
170
+        """Start a directory transfer"""
171
+        worker = DirectoryTransferWorker(self.sftp, source_dir, dest_dir, is_upload)
172
+        
173
+        if progress_callback:
174
+            worker.signals.progress.connect(progress_callback)
175
+        if complete_callback:
176
+            worker.signals.finished.connect(complete_callback)
177
+        if error_callback:
178
+            worker.signals.error.connect(error_callback)
179
+            
180
+        self.threadpool.start(worker)
181
+        return worker
src/wulftp/core/ssh_manager.pyadded
@@ -0,0 +1,65 @@
1
+"""SSH/SFTP connection management for wulFTP."""
2
+
3
+import os
4
+from dataclasses import dataclass
5
+from pathlib import Path
6
+from typing import Dict, Optional
7
+
8
+import paramiko
9
+from paramiko import SSHConfig
10
+from dotenv import load_dotenv
11
+
12
+# Load environment variables
13
+load_dotenv()
14
+
15
+
16
+@dataclass
17
+class SSHHost:
18
+    """Represents an SSH host configuration."""
19
+    alias: str
20
+    hostname: str
21
+    port: int = 22
22
+    user: Optional[str] = None
23
+    key_file: Optional[str] = None
24
+
25
+    def display_name(self) -> str:
26
+        """Get display name for UI."""
27
+        if self.user:
28
+            return f"{self.alias} ({self.user}@{self.hostname})"
29
+        return f"{self.alias} ({self.hostname})"
30
+
31
+
32
+def load_ssh_config() -> Dict[str, SSHHost]:
33
+    """Load SSH hosts from ~/.ssh/config and environment."""
34
+    hosts = {}
35
+    config_path = Path.home() / ".ssh" / "config"
36
+
37
+    if config_path.exists():
38
+        config = SSHConfig()
39
+        with open(config_path) as f:
40
+            config.parse(f)
41
+
42
+        for host in config.get_hostnames():
43
+            if host == "*":
44
+                continue
45
+
46
+            cfg = config.lookup(host)
47
+            hosts[host] = SSHHost(
48
+                alias=host,
49
+                hostname=cfg.get("hostname", host),
50
+                port=int(cfg.get("port", 22)),
51
+                user=cfg.get("user"),
52
+                key_file=cfg.get("identityfile", [None])[0],
53
+            )
54
+
55
+    # Add custom hosts from environment
56
+    if os.getenv("WULFTP_HOST"):
57
+        hosts["env_default"] = SSHHost(
58
+            alias="Default",
59
+            hostname=os.getenv("WULFTP_HOST"),
60
+            port=int(os.getenv("WULFTP_PORT", "22")),
61
+            user=os.getenv("WULFTP_USER"),
62
+            key_file=os.getenv("WULFTP_KEY"),
63
+        )
64
+
65
+    return hosts
src/wulftp/main.pyadded
@@ -0,0 +1,30 @@
1
+"""Main entry point for wulFTP application."""
2
+
3
+import sys
4
+from dotenv import load_dotenv
5
+from PyQt6.QtWidgets import QApplication
6
+
7
+from .ui import WulFTPClient
8
+
9
+# Load environment variables
10
+load_dotenv()
11
+
12
+
13
+def create_application():
14
+    """Create and configure the Qt application."""
15
+    app = QApplication(sys.argv)
16
+    app.setApplicationName("wulFTP")
17
+    app.setStyle("Fusion")
18
+    return app
19
+
20
+
21
+def main():
22
+    """Application entry point."""
23
+    app = create_application()
24
+    window = WulFTPClient()
25
+    window.show()
26
+    sys.exit(app.exec())
27
+
28
+
29
+if __name__ == "__main__":
30
+    main()
src/wulftp/models/__init__.pyadded
@@ -0,0 +1,13 @@
1
+"""Data models for wulFTP."""
2
+
3
+from .remote_model import (
4
+    RemoteFileSystemModel,
5
+    RemoteFileInfo,
6
+    RemoteColumn
7
+)
8
+
9
+__all__ = [
10
+    "RemoteFileSystemModel",
11
+    "RemoteFileInfo",
12
+    "RemoteColumn"
13
+]
src/wulftp/remote_model.py → src/wulftp/models/remote_model.pyrenamed (62% similarity)
@@ -2,7 +2,7 @@ import os
22
 import stat
33
 from datetime import datetime
44
 from enum import IntEnum
5
-from typing import Any, Dict, List, Optional, Union
5
+from typing import Any, Dict, List, Optional, Union, Set
66
 
77
 import qtawesome as qta
88
 from paramiko import SFTPAttributes, SFTPClient
@@ -19,6 +19,15 @@ from PyQt6.QtCore import (
1919
 )
2020
 from PyQt6.QtGui import QIcon
2121
 
22
+from ..core.constants import Icons, FileTypes
23
+from ..utils import (
24
+    format_bytes, 
25
+    format_timestamp, 
26
+    format_permissions,
27
+    get_file_type_description,
28
+    is_hidden_file
29
+)
30
+
2231
 
2332
 class RemoteFileInfo:
2433
     """Represents a remote file or directory"""
@@ -45,61 +54,24 @@ class RemoteFileInfo:
4554
     @property
4655
     def is_hidden(self) -> bool:
4756
         """Check if this is a hidden file (starts with .)"""
48
-        return self.name.startswith(".")
57
+        return is_hidden_file(self.name)
4958
 
5059
     @property
5160
     def size_str(self) -> str:
5261
         """Human-readable file size"""
5362
         if self.is_dir:
5463
             return ""
55
-        
56
-        size = self.attr.st_size
57
-        for unit in ["B", "KB", "MB", "GB", "TB"]:
58
-            if size < 1024.0:
59
-                return f"{size:.1f} {unit}"
60
-            size /= 1024.0
61
-        return f"{size:.1f} PB"
64
+        return format_bytes(self.attr.st_size)
6265
 
6366
     @property
6467
     def modified_str(self) -> str:
6568
         """Human-readable modification time"""
66
-        if self.attr.st_mtime:
67
-            dt = datetime.fromtimestamp(self.attr.st_mtime)
68
-            return dt.strftime("%Y-%m-%d %H:%M")
69
-        return ""
69
+        return format_timestamp(self.attr.st_mtime)
7070
 
7171
     @property
7272
     def permissions_str(self) -> str:
7373
         """Unix-style permissions string"""
74
-        mode = self.attr.st_mode
75
-        perms = ["-"] * 10
76
-
77
-        # File type
78
-        if stat.S_ISDIR(mode):
79
-            perms[0] = "d"
80
-        elif stat.S_ISLNK(mode):
81
-            perms[0] = "l"
82
-        elif stat.S_ISREG(mode):
83
-            perms[0] = "-"
84
-
85
-        # Permissions
86
-        mode_bits = [
87
-            (stat.S_IRUSR, 1, "r"),
88
-            (stat.S_IWUSR, 2, "w"),
89
-            (stat.S_IXUSR, 3, "x"),
90
-            (stat.S_IRGRP, 4, "r"),
91
-            (stat.S_IWGRP, 5, "w"),
92
-            (stat.S_IXGRP, 6, "x"),
93
-            (stat.S_IROTH, 7, "r"),
94
-            (stat.S_IWOTH, 8, "w"),
95
-            (stat.S_IXOTH, 9, "x"),
96
-        ]
97
-
98
-        for bit, pos, char in mode_bits:
99
-            if mode & bit:
100
-                perms[pos] = char
101
-
102
-        return "".join(perms)
74
+        return format_permissions(self.attr.st_mode)
10375
 
10476
     def icon(self) -> QIcon:
10577
         """Get appropriate icon for file type"""
@@ -115,26 +87,28 @@ class RemoteFileInfo:
11587
                                          options=[{}, {"scale_factor": 0.6,
11688
                                                       "offset": (0.4, 0.4)}])
11789
             elif self.is_dir:
118
-                self._icon = qta.icon("fa5s.folder", color="#FFD93D")
90
+                self._icon = qta.icon(Icons.FOLDER.name, color=Icons.FOLDER.color)
11991
             else:
12092
                 # File icon based on extension
12193
                 ext = os.path.splitext(self.name)[1].lower()
122
-                if ext in [".txt", ".md", ".log"]:
123
-                    self._icon = qta.icon("fa5s.file-alt", color="#6C757D")
124
-                elif ext in [".py", ".js", ".cpp", ".c", ".java"]:
125
-                    self._icon = qta.icon("fa5s.file-code", color="#28A745")
126
-                elif ext in [".jpg", ".png", ".gif", ".bmp"]:
127
-                    self._icon = qta.icon("fa5s.file-image", color="#17A2B8")
128
-                elif ext in [".mp3", ".wav", ".flac", ".ogg"]:
129
-                    self._icon = qta.icon("fa5s.file-audio", color="#FD7E14")
130
-                elif ext in [".mp4", ".avi", ".mkv", ".mov"]:
131
-                    self._icon = qta.icon("fa5s.file-video", color="#DC3545")
132
-                elif ext in [".zip", ".tar", ".gz", ".7z", ".rar"]:
133
-                    self._icon = qta.icon("fa5s.file-archive", color="#6F42C1")
134
-                elif ext in [".pdf"]:
135
-                    self._icon = qta.icon("fa5s.file-pdf", color="#DC3545")
94
+                
95
+                # Check file types
96
+                if ext in FileTypes.TEXT:
97
+                    self._icon = qta.icon(Icons.FILE_TEXT.name, color=Icons.FILE_TEXT.color)
98
+                elif ext in FileTypes.CODE:
99
+                    self._icon = qta.icon(Icons.FILE_CODE.name, color=Icons.FILE_CODE.color)
100
+                elif ext in FileTypes.IMAGE:
101
+                    self._icon = qta.icon(Icons.FILE_IMAGE.name, color=Icons.FILE_IMAGE.color)
102
+                elif ext in FileTypes.AUDIO:
103
+                    self._icon = qta.icon(Icons.FILE_AUDIO.name, color=Icons.FILE_AUDIO.color)
104
+                elif ext in FileTypes.VIDEO:
105
+                    self._icon = qta.icon(Icons.FILE_VIDEO.name, color=Icons.FILE_VIDEO.color)
106
+                elif ext in FileTypes.ARCHIVE:
107
+                    self._icon = qta.icon(Icons.FILE_ARCHIVE.name, color=Icons.FILE_ARCHIVE.color)
108
+                elif ext in FileTypes.PDF:
109
+                    self._icon = qta.icon(Icons.FILE_PDF.name, color=Icons.FILE_PDF.color)
136110
                 else:
137
-                    self._icon = qta.icon("fa5s.file", color="#6C757D")
111
+                    self._icon = qta.icon(Icons.FILE.name, color=Icons.FILE.color)
138112
         
139113
         return self._icon
140114
 
@@ -155,11 +129,13 @@ class RemoteFileSystemModel(QAbstractItemModel):
155129
     directoryLoaded = pyqtSignal(str)  # Emitted when a directory is loaded
156130
     errorOccurred = pyqtSignal(str)    # Emitted on errors
157131
     filesDropped = pyqtSignal(list, str)  # Emitted when files are dropped (files, target_directory)
132
+    rootPathChanged = pyqtSignal(str)  # Emitted when the root path changes
158133
 
159134
     def __init__(self, sftp: Optional[SFTPClient] = None, parent: Optional[QObject] = None):
160135
         super().__init__(parent)
161136
         self.sftp = sftp
162137
         self.root_path = "/"
138
+        self.current_root_path = "/"  # The path that's currently shown as root
163139
         
164140
         # Create a proper root item with valid attributes
165141
         root_attr = SFTPAttributes()
@@ -175,24 +151,71 @@ class RemoteFileSystemModel(QAbstractItemModel):
175151
         self.show_hidden = False
176152
         self.show_full_details = False
177153
         
178
-        # Cache for performance (even though we're fetching fresh, 
179
-        # we cache during a single view session)
154
+        # Cache for performance
180155
         self._path_cache: Dict[str, List[RemoteFileInfo]] = {}
181156
         
182
-        # Track which directories are expanded
183
-        self._expanded_paths: set[str] = set()
157
+        # Track expanded directories
158
+        self._expanded_paths: Set[str] = set()
184159
 
185160
     def set_sftp(self, sftp: SFTPClient):
186161
         """Set or update the SFTP connection"""
187162
         self.beginResetModel()
188163
         self.sftp = sftp
189164
         self._path_cache.clear()
165
+        self._expanded_paths.clear()
190166
         self.root_item.children = None
167
+        self.current_root_path = "/"
191168
         self.endResetModel()
192169
         
193170
         if sftp:
194171
             self.load_directory("/")
195172
 
173
+    def set_root_path(self, path: str):
174
+        """Set the root path to display (like QFileSystemModel's setRootPath)"""
175
+        if not self.sftp:
176
+            return
177
+            
178
+        # Don't reset if we're already at this path
179
+        if path == self.current_root_path:
180
+            # Just reload the directory without resetting
181
+            self.load_directory(path)
182
+            return
183
+            
184
+        self.beginResetModel()
185
+        self.current_root_path = path
186
+        self._path_cache.clear()
187
+        
188
+        # Create a new root item for this path
189
+        try:
190
+            # Get attributes for the new root
191
+            attrs = self.sftp.stat(path)
192
+            self.root_item = RemoteFileInfo(attrs, os.path.basename(path) if path != "/" else "/", 
193
+                                          os.path.dirname(path) if path != "/" else "")
194
+            self.root_item.full_path = path
195
+            self.root_item.children = None
196
+            
197
+            # Load the directory
198
+            self.load_directory(path)
199
+            
200
+        except Exception as e:
201
+            self.errorOccurred.emit(f"Failed to change to directory: {str(e)}")
202
+            # Restore to root on error
203
+            self.current_root_path = "/"
204
+            root_attr = SFTPAttributes()
205
+            root_attr.st_mode = stat.S_IFDIR | 0o755
206
+            root_attr.st_size = 0
207
+            root_attr.st_mtime = 0
208
+            root_attr.filename = "/"
209
+            self.root_item = RemoteFileInfo(root_attr, "/", "")
210
+            self.root_item.children = []
211
+            
212
+        self.endResetModel()
213
+        self.rootPathChanged.emit(self.current_root_path)
214
+
215
+    def rootPath(self) -> str:
216
+        """Get the current root path"""
217
+        return self.current_root_path
218
+
196219
     def set_show_hidden(self, show: bool):
197220
         """Toggle showing hidden files"""
198221
         if self.show_hidden != show:
@@ -203,13 +226,24 @@ class RemoteFileSystemModel(QAbstractItemModel):
203226
         """Toggle showing full file details (permissions)"""
204227
         if self.show_full_details != show:
205228
             self.show_full_details = show
206
-            # Just emit dataChanged for the permissions column
229
+            # Only emit dataChanged if we have data
207230
             if self.root_item.children:
208231
                 self.dataChanged.emit(
209232
                     self.index(0, RemoteColumn.PERMISSIONS),
210
-                    self.index(self.rowCount() - 1, RemoteColumn.PERMISSIONS)
233
+                    self.index(len(self.root_item.children) - 1, RemoteColumn.PERMISSIONS)
211234
                 )
212235
 
236
+    def track_expansion(self, path: str, expanded: bool):
237
+        """Track whether a directory is expanded"""
238
+        if expanded:
239
+            self._expanded_paths.add(path)
240
+        else:
241
+            self._expanded_paths.discard(path)
242
+
243
+    def is_path_expanded(self, path: str) -> bool:
244
+        """Check if a path was previously expanded"""
245
+        return path in self._expanded_paths
246
+
213247
     def load_directory(self, path: str) -> bool:
214248
         """Load directory contents from SFTP"""
215249
         if not self.sftp:
@@ -226,7 +260,7 @@ class RemoteFileSystemModel(QAbstractItemModel):
226260
             items = []
227261
             for attr in attrs:
228262
                 name = attr.filename
229
-                if not self.show_hidden and name.startswith("."):
263
+                if not self.show_hidden and is_hidden_file(name):
230264
                     continue
231265
                     
232266
                 file_info = RemoteFileInfo(attr, name, path)
@@ -238,6 +272,19 @@ class RemoteFileSystemModel(QAbstractItemModel):
238272
             # Update cache
239273
             self._path_cache[path] = items
240274
             
275
+            # If this is the root directory we're viewing
276
+            if path == self.current_root_path:
277
+                # Update root item's children
278
+                if items:
279
+                    self.beginInsertRows(QModelIndex(), 0, len(items) - 1)
280
+                
281
+                self.root_item.children = items
282
+                for item in items:
283
+                    item.parent = self.root_item
284
+                
285
+                if items:
286
+                    self.endInsertRows()
287
+            else:
241288
                 # Find the parent item for this path
242289
                 parent_item = self._find_item_by_path(path)
243290
                 if parent_item:
@@ -270,100 +317,40 @@ class RemoteFileSystemModel(QAbstractItemModel):
270317
 
271318
     def refresh(self, preserve_state: bool = True):
272319
         """Refresh current view"""
273
-        if self.root_item.children is not None:
320
+        if not self.sftp:
321
+            return
322
+            
274323
         if preserve_state:
275
-                # Smart refresh - only reload directories that are already loaded
276
-                self._smart_refresh(self.root_item)
324
+            # Smart refresh - update only the current directory without resetting the tree
325
+            self._refresh_directory(self.current_root_path)
326
+            
327
+            # Also refresh any expanded subdirectories
328
+            for expanded_path in list(self._expanded_paths):
329
+                if expanded_path.startswith(self.current_root_path) and expanded_path != self.current_root_path:
330
+                    self._refresh_directory(expanded_path)
277331
         else:
278
-                # Full refresh - clear everything and start over
332
+            # Full refresh - this will collapse everything
279333
             self._path_cache.clear()
334
+            self._expanded_paths.clear()
280335
             self.beginResetModel()
281336
             self.root_item.children = None
282337
             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()
338
+            self.load_directory(self.current_root_path)
322339
 
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:
340
+    def _refresh_directory(self, path: str):
341
+        """Refresh a specific directory without affecting the tree structure"""
342
+        if not self.sftp:
355343
             return
356344
             
357345
         try:
358
-            # Fetch fresh listing
359
-            path = item.full_path if item != self.root_item else "/"
346
+            # Get the current directory listing
360347
             attrs = self.sftp.listdir_attr(path)
361348
             
362349
             # Convert to RemoteFileInfo objects
363350
             new_items = []
364351
             for attr in attrs:
365352
                 name = attr.filename
366
-                if not self.show_hidden and name.startswith("."):
353
+                if not self.show_hidden and is_hidden_file(name):
367354
                     continue
368355
                     
369356
                 file_info = RemoteFileInfo(attr, name, path)
@@ -372,67 +359,89 @@ class RemoteFileSystemModel(QAbstractItemModel):
372359
             # Sort: directories first, then by name
373360
             new_items.sort(key=lambda x: (not x.is_dir, x.name.lower()))
374361
             
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
362
+            # Find the parent item for this path
363
+            if path == self.current_root_path:
364
+                parent_item = self.root_item
365
+                parent_index = QModelIndex()
366
+            else:
367
+                parent_item = self._find_item_by_path(path)
368
+                if not parent_item:
369
+                    return
370
+                parent_index = self._get_index_for_item(parent_item)
371
+            
372
+            # Get old children
373
+            old_children = parent_item.children or []
381374
             
382
-            # Update the model
383
-            parent_index = self._get_index_for_item(item)
375
+            # Create lookup maps
376
+            old_map = {child.name: child for child in old_children}
377
+            new_map = {item.name: item for item in new_items}
384378
             
385
-            # Remove old children
386
-            if item.children:
387
-                self.beginRemoveRows(parent_index, 0, len(item.children) - 1)
388
-                item.children = []
379
+            # Find what needs to be removed, added, or updated
380
+            to_remove = [child for child in old_children if child.name not in new_map]
381
+            to_add = [item for item in new_items if item.name not in old_map]
382
+            to_update = [(old_map[name], new_map[name]) for name in new_map if name in old_map]
383
+            
384
+            # Remove items that no longer exist
385
+            for child in to_remove:
386
+                row = old_children.index(child)
387
+                self.beginRemoveRows(parent_index, row, row)
388
+                old_children.remove(child)
389389
                 self.endRemoveRows()
390390
             
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
391
+            # Update existing items (preserving children if they're directories)
392
+            for old_item, new_item in to_update:
393
+                # Update attributes but preserve children and expansion state
394
+                old_item.attr = new_item.attr
395
+                old_item._icon = None  # Force icon refresh
396
+                row = old_children.index(old_item)
397
+                # Emit data changed for this row
398
+                idx_start = self.index(row, 0, parent_index)
399
+                idx_end = self.index(row, self.columnCount() - 1, parent_index)
400
+                self.dataChanged.emit(idx_start, idx_end)
401
+            
402
+            # Add new items
403
+            if to_add:
404
+                # Find insertion point to maintain sort order
405
+                insert_row = len(old_children)
406
+                self.beginInsertRows(parent_index, insert_row, insert_row + len(to_add) - 1)
407
+                for item in to_add:
408
+                    item.parent = parent_item
409
+                    old_children.append(item)
405410
                 self.endInsertRows()
406
-            else:
407
-                # No children, just set empty list
408
-                item.children = []
411
+                
412
+                # Re-sort the children
413
+                old_children.sort(key=lambda x: (not x.is_dir, x.name.lower()))
414
+            
415
+            # Update parent's children reference
416
+            parent_item.children = old_children
409417
             
410418
             # Update cache
411
-            self._path_cache[path] = new_items
419
+            self._path_cache[path] = old_children
412420
             
413421
         except Exception as e:
414
-            # Silently handle errors during refresh
415
-            pass
422
+            # On error, just do a full reload of this directory
423
+            self.load_directory(path)
416424
 
417
-    def mark_expanded(self, path: str):
418
-        """Mark a directory as expanded"""
419
-        self._expanded_paths.add(path)
425
+    def _find_item_by_path(self, path: str) -> Optional[RemoteFileInfo]:
426
+        """Find item by path traversing the tree"""
427
+        if path == self.current_root_path:
428
+            return self.root_item
420429
             
421
-    def mark_collapsed(self, path: str):
422
-        """Mark a directory as collapsed"""
423
-        self._expanded_paths.discard(path)
430
+        # If the path is not under our current root, return None
431
+        if not path.startswith(self.current_root_path):
432
+            return None
424433
             
425
-    def is_expanded(self, path: str) -> bool:
426
-        """Check if a directory is marked as expanded"""
427
-        return path in self._expanded_paths
434
+        # Get relative path from current root
435
+        if self.current_root_path == "/":
436
+            relative_path = path[1:]  # Remove leading /
437
+        else:
438
+            relative_path = path[len(self.current_root_path):].lstrip("/")
428439
             
429
-    def _find_item_by_path(self, path: str) -> Optional[RemoteFileInfo]:
430
-        """Find item by path traversing the tree"""
431
-        if path == "/" or path == "":
440
+        if not relative_path:
432441
             return self.root_item
433442
             
434443
         # Split path and traverse
435
-        parts = path.strip("/").split("/")
444
+        parts = relative_path.split("/")
436445
         current = self.root_item
437446
         
438447
         for part in parts:
@@ -524,13 +533,7 @@ class RemoteFileSystemModel(QAbstractItemModel):
524533
             elif col == RemoteColumn.SIZE:
525534
                 return item.size_str
526535
             elif col == RemoteColumn.TYPE:
527
-                if item.is_link:
528
-                    return "Link"
529
-                elif item.is_dir:
530
-                    return "Folder"
531
-                else:
532
-                    ext = os.path.splitext(item.name)[1]
533
-                    return f"{ext[1:].upper()} File" if ext else "File"
536
+                return get_file_type_description(item.name, item.is_dir, item.is_link)
534537
             elif col == RemoteColumn.MODIFIED:
535538
                 return item.modified_str
536539
             elif col == RemoteColumn.PERMISSIONS and self.show_full_details:
@@ -561,12 +564,19 @@ class RemoteFileSystemModel(QAbstractItemModel):
561564
     def hasChildren(self, parent: QModelIndex = QModelIndex()) -> bool:
562565
         """Check if item has children (is a directory)"""
563566
         item = self._get_item(parent)
567
+        # For the flat view mode, only root has children
568
+        if self.current_root_path != "/":
569
+            return not parent.isValid()
570
+        # For tree mode, directories have children
564571
         return item.is_dir if item else False
565572
 
566573
     def canFetchMore(self, parent: QModelIndex) -> bool:
567574
         """Check if we need to fetch children for this item"""
575
+        # In flat view mode, we don't fetch more
576
+        if self.current_root_path != "/" and parent.isValid():
577
+            return False
578
+            
568579
         item = self._get_item(parent)
569
-        # Check cache first to avoid redundant fetches
570580
         if item and item.is_dir and item.children is None:
571581
             # Check if we already have it cached
572582
             if item.full_path in self._path_cache:
@@ -692,9 +702,9 @@ class RemoteFileSystemModel(QAbstractItemModel):
692702
         # Get the target directory
693703
         if parent.isValid():
694704
             target_item = self._get_item(parent)
695
-            target_path = target_item.full_path if target_item else "/"
705
+            target_path = target_item.full_path if target_item else self.current_root_path
696706
         else:
697
-            target_path = "/"
707
+            target_path = self.current_root_path
698708
         
699709
         # Extract local file paths from URLs
700710
         local_paths = []
src/wulftp/sftp_backend.pydeleted
@@ -1,61 +0,0 @@
1
-import paramiko
2
-
3
-
4
-class SFTPBackend:
5
-    """
6
-    simple wrapper for SFTP operations using Paramiko.
7
-    """
8
-
9
-    def __init__(self):
10
-        self.ssh = None
11
-        self.sftp = None
12
-
13
-    def connect(
14
-        self,
15
-        host: str,
16
-        port: int,
17
-        username: str,
18
-        key_path: str,
19
-        passphrase: str | None = None,
20
-    ):
21
-        """
22
-        establish an SSH connection and open an SFTP session.
23
-        Raises Paramiko exceptions on failure.
24
-        """
25
-        self.ssh = paramiko.SSHClient()
26
-        self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
27
-        if passphrase:
28
-            pkey = paramiko.RSAKey.from_private_key_file(key_path, password=passphrase)
29
-            self.ssh.connect(hostname=host, port=port, username=username, pkey=pkey)
30
-        else:
31
-            self.ssh.connect(
32
-                hostname=host, port=port, username=username, key_filename=key_path
33
-            )
34
-        self.sftp = self.ssh.open_sftp()
35
-
36
-    def listdir(self, path: str = ".") -> list[str]:
37
-        """
38
-        return the list of entries in the remote directory.
39
-        """
40
-        return self.sftp.listdir(path)
41
-
42
-    def upload(self, local_path: str, remote_path: str) -> None:
43
-        """
44
-        upload a local file to the specified remote path.
45
-        """
46
-        self.sftp.put(local_path, remote_path)
47
-
48
-    def download(self, remote_path: str, local_path: str) -> None:
49
-        """
50
-        download a remote file to the specified local path.
51
-        """
52
-        self.sftp.get(remote_path, local_path)
53
-
54
-    def close(self) -> None:
55
-        """
56
-        close the SFTP session and underlying SSH connection.
57
-        """
58
-        if self.sftp:
59
-            self.sftp.close()
60
-        if self.ssh:
61
-            self.ssh.close()
src/wulftp/ui/__init__.pyadded
@@ -0,0 +1,23 @@
1
+"""UI components for wulFTP."""
2
+
3
+from .widgets import LocalTreeView
4
+from .components import (
5
+    ConnectionToolbar,
6
+    FilePane,
7
+    LocalFilePane,
8
+    RemoteFilePane,
9
+    FileTransferToolbar,
10
+    FileContextMenu
11
+)
12
+from .main_window import WulFTPClient
13
+
14
+__all__ = [
15
+    "LocalTreeView",
16
+    "ConnectionToolbar", 
17
+    "FilePane",
18
+    "LocalFilePane",
19
+    "RemoteFilePane",
20
+    "FileTransferToolbar",
21
+    "FileContextMenu",
22
+    "WulFTPClient"
23
+]
src/wulftp/ui/components.pyadded
@@ -0,0 +1,566 @@
1
+"""UI Components for wulFTP."""
2
+
3
+import os
4
+from typing import Optional, List, Tuple
5
+import qtawesome as qta
6
+from PyQt6.QtCore import Qt, pyqtSignal, QModelIndex, QDir
7
+from PyQt6.QtGui import QAction, QFileSystemModel
8
+from PyQt6.QtWidgets import (
9
+    QWidget, QToolBar, QLabel, QComboBox, QLineEdit,
10
+    QPushButton, QHBoxLayout, QVBoxLayout, QTreeView,
11
+    QHeaderView, QSizePolicy, QAbstractItemView,
12
+    QMenu
13
+)
14
+
15
+from ..core import Icons, AppConfig, Styles
16
+from ..models import RemoteFileSystemModel
17
+from ..ui.widgets import LocalTreeView
18
+
19
+
20
+class ConnectionToolbar(QToolBar):
21
+    """Connection toolbar with host selection and connect button."""
22
+    
23
+    # Signals
24
+    connectRequested = pyqtSignal()
25
+    disconnectRequested = pyqtSignal()
26
+    hostChanged = pyqtSignal(object)  # SSHHost or None
27
+    
28
+    def __init__(self, ssh_hosts: dict, parent=None):
29
+        super().__init__(parent)
30
+        self.ssh_hosts = ssh_hosts
31
+        self._setup_ui()
32
+        
33
+    def _setup_ui(self):
34
+        """Initialize the toolbar UI."""
35
+        self.setMovable(False)
36
+        
37
+        # Host dropdown
38
+        self.addWidget(QLabel(" Host: "))
39
+        self.host_combo = QComboBox()
40
+        self.host_combo.setMinimumWidth(AppConfig.HOST_COMBO_MIN_WIDTH)
41
+        self.host_combo.setMaximumWidth(AppConfig.HOST_COMBO_MAX_WIDTH)
42
+        
43
+        # Populate hosts
44
+        for host in self.ssh_hosts.values():
45
+            self.host_combo.addItem(host.display_name(), host)
46
+        
47
+        # Add custom option
48
+        self.host_combo.addItem("Custom...", None)
49
+        self.host_combo.currentIndexChanged.connect(self._on_host_changed)
50
+        self.addWidget(self.host_combo)
51
+        
52
+        # Custom host fields (hidden by default)
53
+        self.custom_host = QLineEdit()
54
+        self.custom_host.setPlaceholderText("hostname")
55
+        self.custom_host.setVisible(False)
56
+        self.custom_host.setMaximumWidth(AppConfig.CUSTOM_HOST_MAX_WIDTH)
57
+        self.addWidget(self.custom_host)
58
+        
59
+        self.custom_user = QLineEdit()
60
+        self.custom_user.setPlaceholderText("username")
61
+        self.custom_user.setVisible(False)
62
+        self.custom_user.setMaximumWidth(AppConfig.CUSTOM_USER_MAX_WIDTH)
63
+        self.addWidget(self.custom_user)
64
+        
65
+        # Connect button
66
+        self.connect_btn = QPushButton()
67
+        self.connect_btn.setIcon(qta.icon(Icons.CONNECT.name, color=Icons.CONNECT.color))
68
+        self.connect_btn.setText("Connect")
69
+        self.connect_btn.clicked.connect(self._on_connect_clicked)
70
+        self.addWidget(self.connect_btn)
71
+        
72
+        # Connection indicator
73
+        self.conn_indicator = QLabel()
74
+        self.update_connection_status(False)
75
+        self.addWidget(self.conn_indicator)
76
+        
77
+    def _on_host_changed(self, index):
78
+        """Handle host selection change."""
79
+        host = self.host_combo.currentData()
80
+        is_custom = host is None
81
+        
82
+        self.custom_host.setVisible(is_custom)
83
+        self.custom_user.setVisible(is_custom)
84
+        self.hostChanged.emit(host)
85
+        
86
+    def _on_connect_clicked(self):
87
+        """Handle connect button click."""
88
+        if self.connect_btn.text() == "Connect":
89
+            self.connectRequested.emit()
90
+        else:
91
+            self.disconnectRequested.emit()
92
+            
93
+    def update_connection_status(self, connected: bool):
94
+        """Update connection indicators."""
95
+        if connected:
96
+            icon = qta.icon(Icons.CONNECTED.name, color=Icons.CONNECTED.color)
97
+            self.connect_btn.setText("Disconnect")
98
+            self.connect_btn.setIcon(qta.icon(Icons.DISCONNECT.name, color=Icons.DISCONNECT.color))
99
+        else:
100
+            icon = qta.icon(Icons.DISCONNECTED.name, color=Icons.DISCONNECTED.color)
101
+            self.connect_btn.setText("Connect")
102
+            self.connect_btn.setIcon(qta.icon(Icons.CONNECT.name, color=Icons.CONNECT.color))
103
+            
104
+        self.conn_indicator.setPixmap(icon.pixmap(AppConfig.ICON_SIZE, AppConfig.ICON_SIZE))
105
+        
106
+    def get_connection_params(self) -> dict:
107
+        """Get current connection parameters."""
108
+        host_data = self.host_combo.currentData()
109
+        
110
+        if host_data:
111
+            return {
112
+                "hostname": host_data.hostname,
113
+                "port": host_data.port,
114
+                "username": host_data.user,
115
+                "key_file": host_data.key_file,
116
+            }
117
+        else:
118
+            return {
119
+                "hostname": self.custom_host.text(),
120
+                "port": 22,
121
+                "username": self.custom_user.text(),
122
+                "key_file": None,
123
+            }
124
+
125
+
126
+class FileTransferToolbar(QToolBar):
127
+    """File transfer operations toolbar."""
128
+    
129
+    # Signals
130
+    uploadRequested = pyqtSignal()
131
+    downloadRequested = pyqtSignal()
132
+    deleteRequested = pyqtSignal()
133
+    refreshRequested = pyqtSignal()
134
+    showHiddenToggled = pyqtSignal(bool)
135
+    showDetailsToggled = pyqtSignal(bool)
136
+    
137
+    def __init__(self, parent=None):
138
+        super().__init__(parent)
139
+        self._setup_ui()
140
+        self._connected = False
141
+        
142
+    def _setup_ui(self):
143
+        """Initialize the toolbar UI."""
144
+        self.setMovable(False)
145
+        
146
+        # Upload action
147
+        self.upload_action = QAction(
148
+            qta.icon(Icons.UPLOAD.name, color=Icons.UPLOAD.color), "Upload Selected", self
149
+        )
150
+        self.upload_action.triggered.connect(self.uploadRequested.emit)
151
+        self.upload_action.setEnabled(False)
152
+        self.addAction(self.upload_action)
153
+        
154
+        # Download action
155
+        self.download_action = QAction(
156
+            qta.icon(Icons.DOWNLOAD.name, color=Icons.DOWNLOAD.color), "Download Selected", self
157
+        )
158
+        self.download_action.triggered.connect(self.downloadRequested.emit)
159
+        self.download_action.setEnabled(False)
160
+        self.addAction(self.download_action)
161
+        
162
+        self.addSeparator()
163
+        
164
+        # Delete action
165
+        self.delete_action = QAction(
166
+            qta.icon(Icons.DELETE.name, color=Icons.DELETE.color), "Delete Selected", self
167
+        )
168
+        self.delete_action.triggered.connect(self.deleteRequested.emit)
169
+        self.delete_action.setEnabled(True)  # Always enabled
170
+        self.addAction(self.delete_action)
171
+        
172
+        self.addSeparator()
173
+        
174
+        # Refresh action
175
+        refresh_action = QAction(
176
+            qta.icon(Icons.REFRESH.name, color=Icons.REFRESH.color), "Refresh", self
177
+        )
178
+        refresh_action.triggered.connect(self.refreshRequested.emit)
179
+        self.addAction(refresh_action)
180
+        
181
+        # Add spacer to push settings to the right
182
+        spacer = QWidget()
183
+        spacer.setSizePolicy(
184
+            QSizePolicy.Policy.Expanding,
185
+            QSizePolicy.Policy.Expanding
186
+        )
187
+        self.addWidget(spacer)
188
+        
189
+        # Settings actions
190
+        self.show_hidden_action = QAction("Show Hidden Files", self)
191
+        self.show_hidden_action.setCheckable(True)
192
+        self.show_hidden_action.setChecked(False)
193
+        self.show_hidden_action.toggled.connect(self.showHiddenToggled.emit)
194
+        self.addAction(self.show_hidden_action)
195
+        
196
+        self.show_details_action = QAction("Show Full Details", self)
197
+        self.show_details_action.setCheckable(True)
198
+        self.show_details_action.setChecked(False)
199
+        self.show_details_action.toggled.connect(self.showDetailsToggled.emit)
200
+        self.addAction(self.show_details_action)
201
+        
202
+    def set_connection_state(self, connected: bool):
203
+        """Update toolbar based on connection state."""
204
+        self._connected = connected
205
+        self.upload_action.setEnabled(connected)
206
+        self.download_action.setEnabled(connected)
207
+        
208
+    def update_delete_button(self, has_selection: bool):
209
+        """Update delete button appearance based on selection."""
210
+        if has_selection:
211
+            self.delete_action.setIcon(qta.icon(Icons.DELETE.name, color=Icons.DELETE.color))
212
+        else:
213
+            self.delete_action.setIcon(qta.icon(Icons.DELETE_DISABLED.name, color=Icons.DELETE_DISABLED.color))
214
+
215
+
216
+class FilePane(QWidget):
217
+    """File browser pane for local or remote files."""
218
+    
219
+    # Signals
220
+    navigateUp = pyqtSignal()
221
+    navigateHome = pyqtSignal()
222
+    createDirectory = pyqtSignal()
223
+    itemDoubleClicked = pyqtSignal(object)  # QModelIndex
224
+    
225
+    def __init__(self, title: str, parent=None):
226
+        super().__init__(parent)
227
+        self.title = title
228
+        self._setup_ui()
229
+        
230
+    def _setup_ui(self):
231
+        """Initialize the pane UI."""
232
+        layout = QVBoxLayout(self)
233
+        layout.setContentsMargins(5, 5, 5, 5)
234
+        layout.setSpacing(5)
235
+        
236
+        # Header with navigation
237
+        header = QWidget()
238
+        header_layout = QHBoxLayout(header)
239
+        header_layout.setContentsMargins(0, 0, 0, 0)
240
+        
241
+        # Title
242
+        title_label = QLabel(self.title)
243
+        title_label.setStyleSheet(Styles.TITLE_LABEL)
244
+        header_layout.addWidget(title_label)
245
+        
246
+        header_layout.addStretch()
247
+        
248
+        # Navigation buttons
249
+        self.up_btn = QPushButton()
250
+        self.up_btn.setIcon(qta.icon(Icons.UP.name, color=Icons.UP.color))
251
+        self.up_btn.setMaximumSize(AppConfig.NAV_BUTTON_SIZE, AppConfig.NAV_BUTTON_SIZE)
252
+        self.up_btn.setToolTip("Go up one directory")
253
+        self.up_btn.clicked.connect(self.navigateUp.emit)
254
+        header_layout.addWidget(self.up_btn)
255
+        
256
+        self.home_btn = QPushButton()
257
+        self.home_btn.setIcon(qta.icon(Icons.HOME.name, color=Icons.HOME.color))
258
+        self.home_btn.setMaximumSize(AppConfig.NAV_BUTTON_SIZE, AppConfig.NAV_BUTTON_SIZE)
259
+        self.home_btn.setToolTip("Go to home directory")
260
+        self.home_btn.clicked.connect(self.navigateHome.emit)
261
+        header_layout.addWidget(self.home_btn)
262
+        
263
+        # Create directory button
264
+        self.create_dir_btn = QPushButton()
265
+        self.create_dir_btn.setIcon(qta.icon(Icons.FOLDER_NEW.name, color=Icons.FOLDER_NEW.color))
266
+        self.create_dir_btn.setMaximumSize(AppConfig.NAV_BUTTON_SIZE, AppConfig.NAV_BUTTON_SIZE)
267
+        self.create_dir_btn.setToolTip("Create new directory")
268
+        self.create_dir_btn.clicked.connect(self.createDirectory.emit)
269
+        header_layout.addWidget(self.create_dir_btn)
270
+        
271
+        layout.addWidget(header)
272
+        
273
+        # Path display
274
+        self.path_edit = QLineEdit()
275
+        self.path_edit.setReadOnly(True)
276
+        self.path_edit.setStyleSheet(Styles.PATH_EDIT)
277
+        layout.addWidget(self.path_edit)
278
+        
279
+        # File view (to be set by caller)
280
+        self.tree_view = None
281
+        
282
+    def set_tree_view(self, tree_view: QTreeView):
283
+        """Set the tree view for this pane."""
284
+        self.tree_view = tree_view
285
+        self.layout().addWidget(tree_view)
286
+        
287
+        # Connect double-click
288
+        tree_view.doubleClicked.connect(self.itemDoubleClicked.emit)
289
+        
290
+    def set_path(self, path: str):
291
+        """Update the path display."""
292
+        self.path_edit.setText(path)
293
+        
294
+    def get_path(self) -> str:
295
+        """Get the current path."""
296
+        return self.path_edit.text()
297
+
298
+
299
+class LocalFilePane(FilePane):
300
+    """File browser pane specifically for local files."""
301
+    
302
+    def __init__(self, parent=None):
303
+        super().__init__("Local Files", parent)
304
+        self._setup_file_view()
305
+        
306
+    def _setup_file_view(self):
307
+        """Set up the local file view."""
308
+        # Local file system model
309
+        self.model = QFileSystemModel()
310
+        self.model.setRootPath(QDir.homePath())
311
+        
312
+        # Use custom tree view for local files
313
+        self.tree_view = LocalTreeView()
314
+        self.tree_view.setModel(self.model)
315
+        self.tree_view.setRootIndex(self.model.index(QDir.homePath()))
316
+        self.tree_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
317
+        
318
+        # Enable drag from local view
319
+        self.tree_view.setDragEnabled(True)
320
+        self.tree_view.setDefaultDropAction(Qt.DropAction.CopyAction)
321
+        self.tree_view.setDropIndicatorShown(True)
322
+        
323
+        # Configure columns
324
+        self.tree_view.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
325
+        self.tree_view.setColumnWidth(1, AppConfig.COLUMN_SIZE)  # Size
326
+        self.tree_view.setColumnWidth(2, AppConfig.COLUMN_TYPE)  # Type
327
+        self.tree_view.setColumnWidth(3, AppConfig.COLUMN_DATE)  # Date
328
+        
329
+        # Context menu
330
+        self.tree_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
331
+        
332
+        # Set initial path
333
+        self.set_path(QDir.homePath())
334
+        
335
+        # Add tree view to layout
336
+        self.set_tree_view(self.tree_view)
337
+        
338
+        # Connect double-click handler
339
+        self.tree_view.doubleClicked.connect(self._on_double_click)
340
+        
341
+    def _on_double_click(self, index: QModelIndex):
342
+        """Handle double-click on local file."""
343
+        if self.model.isDir(index):
344
+            self.tree_view.setRootIndex(index)
345
+            self.set_path(self.model.filePath(index))
346
+            
347
+    def navigate_up(self):
348
+        """Navigate up one directory."""
349
+        current = self.tree_view.rootIndex()
350
+        parent = self.model.parent(current)
351
+        if parent.isValid():
352
+            self.tree_view.setRootIndex(parent)
353
+            self.set_path(self.model.filePath(parent))
354
+            
355
+    def navigate_home(self):
356
+        """Navigate to home directory."""
357
+        home_index = self.model.index(QDir.homePath())
358
+        self.tree_view.setRootIndex(home_index)
359
+        self.set_path(QDir.homePath())
360
+        
361
+        # Restore focus after navigation
362
+        self.window().raise_()
363
+        self.window().activateWindow()
364
+        
365
+    def get_selected_items(self) -> List[Tuple[str, bool]]:
366
+        """Get selected items as list of (path, is_dir) tuples."""
367
+        items = []
368
+        for index in self.tree_view.selectedIndexes():
369
+            if index.column() == 0:  # Only process name column
370
+                path = self.model.filePath(index)
371
+                is_dir = self.model.isDir(index)
372
+                items.append((path, is_dir))
373
+        return items
374
+
375
+
376
+class RemoteFilePane(FilePane):
377
+    """File browser pane specifically for remote files."""
378
+    
379
+    def __init__(self, parent=None):
380
+        super().__init__("Remote Files", parent)
381
+        self._setup_file_view()
382
+        
383
+    def _setup_file_view(self):
384
+        """Set up the remote file view."""
385
+        # Remote file system model
386
+        self.model = RemoteFileSystemModel()
387
+        
388
+        # Tree view for remote files
389
+        self.tree_view = QTreeView()
390
+        self.tree_view.setModel(self.model)
391
+        self.tree_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
392
+        
393
+        # Enable drag and drop
394
+        self.tree_view.setDragEnabled(True)
395
+        self.tree_view.setAcceptDrops(True)
396
+        self.tree_view.setDefaultDropAction(Qt.DropAction.CopyAction)
397
+        self.tree_view.setDropIndicatorShown(True)
398
+        
399
+        # Configure columns
400
+        self.tree_view.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
401
+        self.tree_view.setColumnWidth(1, AppConfig.COLUMN_SIZE)  # Size
402
+        self.tree_view.setColumnWidth(2, AppConfig.COLUMN_TYPE)  # Type
403
+        self.tree_view.setColumnWidth(3, AppConfig.COLUMN_DATE)  # Modified
404
+        
405
+        # Performance optimizations
406
+        self.tree_view.setUniformRowHeights(True)
407
+        self.tree_view.setAlternatingRowColors(True)
408
+        
409
+        # Context menu
410
+        self.tree_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
411
+        
412
+        # Connect model signals
413
+        self.model.directoryLoaded.connect(lambda path: self.set_path(path))
414
+        
415
+        # Track expansion state
416
+        self.tree_view.expanded.connect(self._on_item_expanded)
417
+        self.tree_view.collapsed.connect(self._on_item_collapsed)
418
+        
419
+        # Add tree view to layout
420
+        self.set_tree_view(self.tree_view)
421
+        
422
+        # Connect double-click handler
423
+        self.tree_view.doubleClicked.connect(self._on_double_click)
424
+        
425
+        # Start disabled until connected
426
+        self.setEnabled(False)
427
+    
428
+    def _on_item_expanded(self, index: QModelIndex):
429
+        """Track when an item is expanded"""
430
+        file_info = self.model.fileInfo(index)
431
+        if file_info and file_info.is_dir:
432
+            self.model.track_expansion(file_info.full_path, True)
433
+    
434
+    def _on_item_collapsed(self, index: QModelIndex):
435
+        """Track when an item is collapsed"""
436
+        file_info = self.model.fileInfo(index)
437
+        if file_info and file_info.is_dir:
438
+            self.model.track_expansion(file_info.full_path, False)
439
+        
440
+    def _on_double_click(self, index: QModelIndex):
441
+        """Handle double-click on remote file."""
442
+        if self.model.isDir(index):
443
+            path = self.model.filePath(index)
444
+            self.model.set_root_path(path)
445
+            self.set_path(path)
446
+            
447
+    def navigate_up(self):
448
+        """Navigate up one directory."""
449
+        current_path = self.model.rootPath()
450
+        if current_path and current_path != "/":
451
+            parent_path = os.path.dirname(current_path)
452
+            self.model.set_root_path(parent_path)
453
+            self.set_path(parent_path)
454
+            
455
+    def navigate_home(self):
456
+        """Navigate to home directory."""
457
+        if self.model.sftp:
458
+            try:
459
+                # Try to get the home directory from SFTP
460
+                home_path = self.model.sftp.normalize(".")
461
+                self.model.set_root_path(home_path)
462
+                self.set_path(home_path)
463
+            except:
464
+                # Fall back to root
465
+                self.model.set_root_path("/")
466
+                self.set_path("/")
467
+                
468
+        # Restore focus after navigation
469
+        self.window().raise_()
470
+        self.window().activateWindow()
471
+                
472
+    def get_selected_items(self) -> List[Tuple[str, bool, str]]:
473
+        """Get selected items as list of (path, is_dir, name) tuples."""
474
+        items = []
475
+        for index in self.tree_view.selectedIndexes():
476
+            if index.column() == 0:  # Only process name column
477
+                file_info = self.model.fileInfo(index)
478
+                if file_info:
479
+                    items.append((file_info.full_path, file_info.is_dir, file_info.name))
480
+        return items
481
+        
482
+    def set_show_hidden(self, show: bool):
483
+        """Toggle showing hidden files."""
484
+        self.model.set_show_hidden(show)
485
+        
486
+    def set_show_details(self, show: bool):
487
+        """Toggle showing full details."""
488
+        self.model.set_show_full_details(show)
489
+        if show:
490
+            self.tree_view.showColumn(4)  # Permissions column
491
+        else:
492
+            self.tree_view.hideColumn(4)
493
+            
494
+    def refresh(self):
495
+        """Refresh the remote view."""
496
+        if self.model.sftp:
497
+            # Save expansion state before refresh
498
+            expanded_paths = list(self.model._expanded_paths)
499
+            
500
+            # Refresh preserving state
501
+            self.model.refresh(preserve_state=True)
502
+            
503
+            # Restore expansion state
504
+            for path in expanded_paths:
505
+                # Find the item and expand it
506
+                item = self.model._find_item_by_path(path)
507
+                if item:
508
+                    index = self.model._get_index_for_item(item)
509
+                    if index.isValid():
510
+                        self.tree_view.setExpanded(index, True)
511
+
512
+
513
+class FileContextMenu(QMenu):
514
+    """Context menu for file operations."""
515
+    
516
+    # Signals
517
+    uploadRequested = pyqtSignal()
518
+    downloadRequested = pyqtSignal()
519
+    deleteRequested = pyqtSignal()
520
+    createDirectoryRequested = pyqtSignal()
521
+    refreshRequested = pyqtSignal()
522
+    
523
+    def __init__(self, is_local: bool, has_selection: bool, is_connected: bool, parent=None):
524
+        super().__init__(parent)
525
+        self.is_local = is_local
526
+        self.has_selection = has_selection
527
+        self.is_connected = is_connected
528
+        self._setup_menu()
529
+        
530
+    def _setup_menu(self):
531
+        """Set up the context menu."""
532
+        if self.is_connected:  # Only show transfer options when connected
533
+            if self.is_local:
534
+                upload_action = self.addAction(
535
+                    qta.icon(Icons.UPLOAD.name, color=Icons.UPLOAD.color), "Upload"
536
+                )
537
+                upload_action.triggered.connect(self.uploadRequested.emit)
538
+                upload_action.setEnabled(self.has_selection)
539
+            else:
540
+                download_action = self.addAction(
541
+                    qta.icon(Icons.DOWNLOAD.name, color=Icons.DOWNLOAD.color), "Download"
542
+                )
543
+                download_action.triggered.connect(self.downloadRequested.emit)
544
+                download_action.setEnabled(self.has_selection)
545
+        
546
+        self.addSeparator()
547
+        
548
+        # Create directory action
549
+        create_dir_action = self.addAction(
550
+            qta.icon(Icons.FOLDER_NEW.name, color=Icons.FOLDER_NEW.color), "Create Directory"
551
+        )
552
+        create_dir_action.triggered.connect(self.createDirectoryRequested.emit)
553
+        
554
+        # Delete action
555
+        if self.has_selection:
556
+            delete_action = self.addAction(
557
+                qta.icon(Icons.DELETE.name, color=Icons.DELETE.color), "Delete"
558
+            )
559
+            delete_action.triggered.connect(self.deleteRequested.emit)
560
+        
561
+        self.addSeparator()
562
+        
563
+        refresh_action = self.addAction(
564
+            qta.icon(Icons.REFRESH.name, color=Icons.REFRESH.color), "Refresh"
565
+        )
566
+        refresh_action.triggered.connect(self.refreshRequested.emit)
src/wulftp/ui/main_window.pyadded
@@ -0,0 +1,730 @@
1
+"""Main application window for wulFTP."""
2
+
3
+import os
4
+import stat
5
+import shutil
6
+from typing import List, Optional
7
+
8
+import paramiko
9
+import qtawesome as qta
10
+from PyQt6.QtCore import (
11
+    Qt,
12
+    QThreadPool,
13
+    pyqtSlot,
14
+    QTimer,
15
+)
16
+from PyQt6.QtWidgets import (
17
+    QApplication,
18
+    QLabel,
19
+    QMainWindow,
20
+    QMessageBox,
21
+    QProgressBar,
22
+    QSplitter,
23
+    QStatusBar,
24
+    QVBoxLayout,
25
+    QWidget,
26
+    QInputDialog,
27
+)
28
+
29
+from ..core import (
30
+    FileTransferManager,
31
+    AppConfig,
32
+    load_ssh_config
33
+)
34
+from ..utils import format_status_message
35
+from ..ui.components import (
36
+    ConnectionToolbar,
37
+    FileTransferToolbar,
38
+    LocalFilePane,
39
+    RemoteFilePane,
40
+    FileContextMenu
41
+)
42
+
43
+
44
+class WulFTPClient(QMainWindow):
45
+    """Main application window for wulFTP."""
46
+    
47
+    def __init__(self):
48
+        super().__init__()
49
+        self.ssh = None
50
+        self.sftp = None
51
+        self.threadpool = QThreadPool()
52
+        self.transfer_manager = None
53
+        self.ssh_hosts = load_ssh_config()
54
+        
55
+        self.init_ui()
56
+        self._connect_signals()
57
+
58
+    def init_ui(self):
59
+        """Initialize the user interface."""
60
+        self.setWindowTitle(AppConfig.WINDOW_TITLE)
61
+        self.setGeometry(AppConfig.WINDOW_X, AppConfig.WINDOW_Y, 
62
+                        AppConfig.WINDOW_WIDTH, AppConfig.WINDOW_HEIGHT)
63
+
64
+        # Central widget
65
+        central = QWidget()
66
+        self.setCentralWidget(central)
67
+
68
+        # Main layout - no margins for maximum space
69
+        layout = QVBoxLayout(central)
70
+        layout.setContentsMargins(0, 0, 0, 0)
71
+        layout.setSpacing(0)
72
+
73
+        # Create status bar
74
+        self._create_status_bar()
75
+
76
+        # Create toolbars
77
+        self._create_toolbars()
78
+
79
+        # Create file panes
80
+        splitter = self._create_file_panes()
81
+        layout.addWidget(splitter)
82
+        
83
+        # Initial state
84
+        self._update_connection_state(False)
85
+        self._update_button_states()
86
+
87
+    def _restore_focus(self):
88
+        """Restore window focus - used after dialogs and operations."""
89
+        QTimer.singleShot(100, lambda: (self.raise_(), self.activateWindow()))
90
+
91
+    def _create_status_bar(self):
92
+        """Create the status bar."""
93
+        self.status_bar = QStatusBar()
94
+        self.setStatusBar(self.status_bar)
95
+
96
+        # Progress bar (hidden by default)
97
+        self.progress_bar = QProgressBar()
98
+        self.progress_bar.setVisible(False)
99
+        self.status_bar.addPermanentWidget(self.progress_bar)
100
+
101
+        # Connection status
102
+        self.conn_status_label = QLabel(AppConfig.MSG_DISCONNECTED)
103
+        self.status_bar.addPermanentWidget(self.conn_status_label)
104
+
105
+    def _create_toolbars(self):
106
+        """Create application toolbars."""
107
+        # Connection toolbar
108
+        self.connection_toolbar = ConnectionToolbar(self.ssh_hosts)
109
+        self.addToolBar(self.connection_toolbar)
110
+        
111
+        # Separator
112
+        self.addToolBarBreak()
113
+        
114
+        # File transfer toolbar
115
+        self.transfer_toolbar = FileTransferToolbar()
116
+        self.addToolBar(self.transfer_toolbar)
117
+
118
+    def _create_file_panes(self) -> QSplitter:
119
+        """Create the file browser panes."""
120
+        splitter = QSplitter(Qt.Orientation.Horizontal)
121
+        splitter.setContentsMargins(0, 0, 0, 0)
122
+
123
+        # Local pane
124
+        self.local_pane = LocalFilePane()
125
+        splitter.addWidget(self.local_pane)
126
+
127
+        # Remote pane
128
+        self.remote_pane = RemoteFilePane()
129
+        splitter.addWidget(self.remote_pane)
130
+
131
+        # Set initial splitter sizes
132
+        splitter.setSizes(AppConfig.SPLITTER_SIZES)
133
+        
134
+        return splitter
135
+
136
+    def _connect_signals(self):
137
+        """Connect all signals to their handlers."""
138
+        # Connection toolbar signals
139
+        self.connection_toolbar.connectRequested.connect(self.connect)
140
+        self.connection_toolbar.disconnectRequested.connect(self.disconnect)
141
+        
142
+        # Transfer toolbar signals
143
+        self.transfer_toolbar.uploadRequested.connect(self.upload_selected)
144
+        self.transfer_toolbar.downloadRequested.connect(self.download_selected)
145
+        self.transfer_toolbar.deleteRequested.connect(self.delete_selected)
146
+        self.transfer_toolbar.refreshRequested.connect(self.refresh_views)
147
+        self.transfer_toolbar.showHiddenToggled.connect(self.remote_pane.set_show_hidden)
148
+        self.transfer_toolbar.showDetailsToggled.connect(self.remote_pane.set_show_details)
149
+        
150
+        # Local pane signals
151
+        self.local_pane.navigateUp.connect(self.local_pane.navigate_up)
152
+        self.local_pane.navigateHome.connect(self.local_pane.navigate_home)
153
+        self.local_pane.createDirectory.connect(lambda: self._create_directory(True))
154
+        self.local_pane.tree_view.remoteFilesDropped.connect(self._handle_remote_files_dropped)
155
+        self.local_pane.tree_view.selectionModel().selectionChanged.connect(self._update_button_states)
156
+        self.local_pane.tree_view.customContextMenuRequested.connect(
157
+            lambda pos: self._show_context_menu(pos, True)
158
+        )
159
+        
160
+        # Remote pane signals
161
+        self.remote_pane.navigateUp.connect(self.remote_pane.navigate_up)
162
+        self.remote_pane.navigateHome.connect(self.remote_pane.navigate_home)
163
+        self.remote_pane.createDirectory.connect(lambda: self._create_directory(False))
164
+        self.remote_pane.model.errorOccurred.connect(
165
+            lambda err: QMessageBox.warning(self, "Error", err)
166
+        )
167
+        self.remote_pane.model.filesDropped.connect(self._handle_files_dropped)
168
+        self.remote_pane.tree_view.selectionModel().selectionChanged.connect(self._update_button_states)
169
+        self.remote_pane.tree_view.customContextMenuRequested.connect(
170
+            lambda pos: self._show_context_menu(pos, False)
171
+        )
172
+
173
+    def _update_connection_state(self, connected: bool):
174
+        """Update UI based on connection state."""
175
+        # Update toolbars
176
+        self.connection_toolbar.update_connection_status(connected)
177
+        self.transfer_toolbar.set_connection_state(connected)
178
+        
179
+        # Update remote pane
180
+        self.remote_pane.setEnabled(connected)
181
+        
182
+        # Update status bar
183
+        if connected:
184
+            self.conn_status_label.setText("Connected")
185
+        else:
186
+            self.conn_status_label.setText("Disconnected")
187
+
188
+    def connect(self):
189
+        """Establish SFTP connection."""
190
+        try:
191
+            params = self.connection_toolbar.get_connection_params()
192
+            
193
+            # Show status message immediately
194
+            self.status_bar.showMessage(AppConfig.MSG_CONNECTING)
195
+            
196
+            # Process events to update UI
197
+            QApplication.processEvents()
198
+            
199
+            # Create SSH client
200
+            self.ssh = paramiko.SSHClient()
201
+            self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
202
+
203
+            # Connection parameters
204
+            connect_kwargs = {
205
+                "hostname": params["hostname"],
206
+                "port": params["port"],
207
+                "username": params["username"],
208
+                "timeout": AppConfig.CONNECTION_TIMEOUT,
209
+            }
210
+
211
+            if params["key_file"] and os.path.exists(os.path.expanduser(params["key_file"])):
212
+                connect_kwargs["key_filename"] = os.path.expanduser(params["key_file"])
213
+
214
+            # Allow password fallback
215
+            connect_kwargs["look_for_keys"] = True
216
+            connect_kwargs["allow_agent"] = True
217
+
218
+            self.ssh.connect(**connect_kwargs)
219
+            self.sftp = self.ssh.open_sftp()
220
+
221
+            # Create transfer manager
222
+            self.transfer_manager = FileTransferManager(self.sftp, self.threadpool)
223
+
224
+            # Update remote model with SFTP connection
225
+            self.remote_pane.model.set_sftp(self.sftp)
226
+            
227
+            # Update UI
228
+            self._update_connection_state(True)
229
+
230
+            self.status_bar.showMessage(
231
+                AppConfig.MSG_CONNECTED.format(params["hostname"]), 
232
+                AppConfig.STATUS_MESSAGE_TIMEOUT
233
+            )
234
+            
235
+            # Defer focus restoration to ensure it happens after all UI updates
236
+            # Using a slightly longer delay to ensure all connection-related 
237
+            # UI updates have completed
238
+            QTimer.singleShot(200, lambda: (
239
+                self.raise_(), 
240
+                self.activateWindow(),
241
+                self.remote_pane.tree_view.setFocus()  # Also set focus to remote pane
242
+            ))
243
+
244
+        except Exception as e:
245
+            QMessageBox.critical(
246
+                self, 
247
+                "Connection Error", 
248
+                str(e)
249
+            )
250
+            self._update_connection_state(False)
251
+            # Restore focus after error dialog
252
+            self._restore_focus()
253
+
254
+    def disconnect(self):
255
+        """Close SFTP connection."""
256
+        if self.sftp:
257
+            self.sftp.close()
258
+            self.sftp = None
259
+        if self.ssh:
260
+            self.ssh.close()
261
+            self.ssh = None
262
+
263
+        # Clear remote model
264
+        self.remote_pane.model.set_sftp(None)
265
+        self.transfer_manager = None
266
+        
267
+        self._update_connection_state(False)
268
+        self.status_bar.showMessage(AppConfig.MSG_DISCONNECTED, AppConfig.STATUS_MESSAGE_TIMEOUT)
269
+
270
+    def refresh_views(self):
271
+        """Refresh both file views."""
272
+        # Local is automatically updated by QFileSystemModel
273
+        if self.sftp:
274
+            self.remote_pane.refresh()
275
+
276
+    def _update_button_states(self):
277
+        """Update button states based on current selection."""
278
+        # Check if anything is selected in either view
279
+        local_has_selection = bool(self.local_pane.tree_view.selectedIndexes())
280
+        remote_has_selection = bool(self.remote_pane.tree_view.selectedIndexes()) if self.sftp else False
281
+        
282
+        has_any_selection = local_has_selection or remote_has_selection
283
+        
284
+        # Update delete button appearance
285
+        self.transfer_toolbar.update_delete_button(has_any_selection)
286
+
287
+    def _show_context_menu(self, pos, is_local: bool):
288
+        """Show context menu for file operations."""
289
+        if is_local:
290
+            view = self.local_pane.tree_view
291
+            has_selection = bool(self.local_pane.get_selected_items())
292
+        else:
293
+            view = self.remote_pane.tree_view
294
+            has_selection = bool(self.remote_pane.get_selected_items())
295
+        
296
+        menu = FileContextMenu(is_local, has_selection, bool(self.sftp))
297
+        
298
+        # Connect menu signals
299
+        menu.uploadRequested.connect(self.upload_selected)
300
+        menu.downloadRequested.connect(self.download_selected)
301
+        menu.deleteRequested.connect(lambda: self._delete_selected(is_local))
302
+        menu.createDirectoryRequested.connect(lambda: self._create_directory(is_local))
303
+        menu.refreshRequested.connect(self.refresh_views)
304
+        
305
+        # Show menu at cursor position
306
+        menu.exec(view.mapToGlobal(pos))
307
+        
308
+        # Restore focus after context menu
309
+        QTimer.singleShot(100, lambda: self.activateWindow())
310
+
311
+    def upload_selected(self):
312
+        """Upload selected local files and directories."""
313
+        if not self.sftp or not self.transfer_manager:
314
+            return
315
+
316
+        items = self.local_pane.get_selected_items()
317
+        if not items:
318
+            return
319
+
320
+        # Separate files and directories
321
+        files = [path for path, is_dir in items if not is_dir]
322
+        directories = [path for path, is_dir in items if is_dir]
323
+        
324
+        # Handle directories first
325
+        if directories:
326
+            reply = QMessageBox.question(
327
+                self,
328
+                "Upload Directories",
329
+                format_status_message("Upload", len(directories), "directory") + "?\n\n" +
330
+                "This will recursively upload all contents.",
331
+                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
332
+                QMessageBox.StandardButton.Yes
333
+            )
334
+            
335
+            self._restore_focus()  # Restore focus after dialog
336
+            
337
+            if reply == QMessageBox.StandardButton.Yes:
338
+                for dir_path in directories:
339
+                    dirname = os.path.basename(dir_path)
340
+                    remote_base = self.remote_pane.model.rootPath()
341
+                    remote_path = os.path.join(remote_base, dirname).replace('\\', '/')
342
+                    
343
+                    self.transfer_manager.transfer_directory(
344
+                        dir_path, remote_path, is_upload=True,
345
+                        progress_callback=self._update_progress,
346
+                        complete_callback=self._transfer_complete,
347
+                        error_callback=self._transfer_error
348
+                    )
349
+                    
350
+                    self.progress_bar.setVisible(True)
351
+                    self.progress_bar.setValue(0)
352
+                    
353
+                    self.status_bar.showMessage(
354
+                        AppConfig.MSG_UPLOAD_DIR.format(dirname), 
355
+                        AppConfig.STATUS_MESSAGE_TIMEOUT
356
+                    )
357
+        
358
+        # Handle files
359
+        if files:
360
+            self._transfer_files(files, is_upload=True)
361
+
362
+    def download_selected(self):
363
+        """Download selected remote files and directories."""
364
+        if not self.sftp or not self.transfer_manager:
365
+            return
366
+
367
+        items = self.remote_pane.get_selected_items()
368
+        if not items:
369
+            return
370
+
371
+        # Separate files and directories
372
+        files = [path for path, is_dir, name in items if not is_dir]
373
+        directories = [(path, name) for path, is_dir, name in items if is_dir]
374
+        
375
+        # Handle directories first
376
+        if directories:
377
+            reply = QMessageBox.question(
378
+                self,
379
+                "Download Directories",
380
+                format_status_message("Download", len(directories), "directory") + "?\n\n" +
381
+                "This will recursively download all contents.",
382
+                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
383
+                QMessageBox.StandardButton.Yes
384
+            )
385
+            
386
+            if reply == QMessageBox.StandardButton.Yes:
387
+                for dir_path, dirname in directories:
388
+                    local_base = self.local_pane.model.filePath(self.local_pane.tree_view.rootIndex())
389
+                    local_path = os.path.join(local_base, dirname)
390
+                    
391
+                    self.transfer_manager.transfer_directory(
392
+                        dir_path, local_path, is_upload=False,
393
+                        progress_callback=self._update_progress,
394
+                        complete_callback=self._transfer_complete_download,
395
+                        error_callback=self._transfer_error
396
+                    )
397
+                    
398
+                    self.progress_bar.setVisible(True)
399
+                    self.progress_bar.setValue(0)
400
+                    
401
+                    self.status_bar.showMessage(
402
+                        AppConfig.MSG_DOWNLOAD_DIR.format(dirname), 
403
+                        AppConfig.STATUS_MESSAGE_TIMEOUT
404
+                    )
405
+
406
+        # Handle files
407
+        if files:
408
+            self._transfer_files(files, is_upload=False)
409
+
410
+    def _transfer_files(self, files: List[str], is_upload: bool):
411
+        """Transfer files with progress tracking."""
412
+        if not self.transfer_manager:
413
+            return
414
+            
415
+        for file_path in files:
416
+            filename = os.path.basename(file_path)
417
+
418
+            if is_upload:
419
+                remote_base = self.remote_pane.model.rootPath()
420
+                remote_path = os.path.join(remote_base, filename).replace('\\', '/')
421
+                source, dest = file_path, remote_path
422
+            else:
423
+                local_base = self.local_pane.model.filePath(self.local_pane.tree_view.rootIndex())
424
+                local_path = os.path.join(local_base, filename)
425
+                source, dest = file_path, local_path
426
+
427
+            self.transfer_manager.transfer_file(
428
+                source, dest, is_upload,
429
+                progress_callback=self._update_progress,
430
+                complete_callback=self._transfer_complete,
431
+                error_callback=self._transfer_error
432
+            )
433
+
434
+            self.progress_bar.setVisible(True)
435
+            self.progress_bar.setValue(0)
436
+
437
+    @pyqtSlot(int)
438
+    def _update_progress(self, value):
439
+        """Update progress bar."""
440
+        self.progress_bar.setValue(value)
441
+
442
+    @pyqtSlot()
443
+    def _transfer_complete(self):
444
+        """Handle transfer completion."""
445
+        self.progress_bar.setVisible(False)
446
+        if self.sftp:
447
+            # Just refresh - the model will preserve state
448
+            self.remote_pane.refresh()
449
+        self.status_bar.showMessage(AppConfig.MSG_TRANSFER_COMPLETE, AppConfig.STATUS_MESSAGE_TIMEOUT)
450
+
451
+    @pyqtSlot()
452
+    def _transfer_complete_download(self):
453
+        """Handle download completion - no need to refresh remote."""
454
+        self.progress_bar.setVisible(False)
455
+        self.status_bar.showMessage("Download complete", AppConfig.STATUS_MESSAGE_TIMEOUT)
456
+
457
+    @pyqtSlot(str)
458
+    def _transfer_error(self, error):
459
+        """Handle transfer error."""
460
+        self.progress_bar.setVisible(False)
461
+        QMessageBox.critical(
462
+            self, 
463
+            "Transfer Error", 
464
+            error
465
+        )
466
+
467
+    def _handle_files_dropped(self, local_paths: List[str], target_directory: str):
468
+        """Handle files and directories dropped onto remote view."""
469
+        if not self.sftp or not self.transfer_manager:
470
+            return
471
+            
472
+        self.remote_pane.set_path(target_directory)
473
+        
474
+        # Separate files and directories
475
+        files = []
476
+        directories = []
477
+        
478
+        for local_path in local_paths:
479
+            if os.path.isdir(local_path):
480
+                directories.append(local_path)
481
+            else:
482
+                files.append(local_path)
483
+        
484
+        # Handle directories
485
+        if directories:
486
+            reply = QMessageBox.question(
487
+                self,
488
+                "Upload Directories",
489
+                format_status_message("Upload", len(directories), "directory") + "?\n\n" +
490
+                "This will recursively upload all contents.",
491
+                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
492
+                QMessageBox.StandardButton.Yes
493
+            )
494
+            
495
+            if reply == QMessageBox.StandardButton.Yes:
496
+                for dir_path in directories:
497
+                    dirname = os.path.basename(dir_path)
498
+                    remote_path = os.path.join(target_directory, dirname).replace('\\', '/')
499
+                    
500
+                    self.transfer_manager.transfer_directory(
501
+                        dir_path, remote_path, is_upload=True,
502
+                        progress_callback=self._update_progress,
503
+                        complete_callback=self._transfer_complete,
504
+                        error_callback=self._transfer_error
505
+                    )
506
+                    
507
+                    self.progress_bar.setVisible(True)
508
+                    self.progress_bar.setValue(0)
509
+        
510
+        # Handle files
511
+        for local_path in files:
512
+            filename = os.path.basename(local_path)
513
+            remote_path = os.path.join(target_directory, filename).replace('\\', '/')
514
+            
515
+            self.transfer_manager.transfer_file(
516
+                local_path, remote_path, is_upload=True,
517
+                progress_callback=self._update_progress,
518
+                complete_callback=self._transfer_complete,
519
+                error_callback=self._transfer_error
520
+            )
521
+            
522
+            self.progress_bar.setVisible(True)
523
+            self.progress_bar.setValue(0)
524
+            
525
+        total_items = len(files) + len(directories)
526
+        self.status_bar.showMessage(
527
+            f"Uploading {total_items} item(s) to {target_directory}", 
528
+            AppConfig.STATUS_MESSAGE_TIMEOUT
529
+        )
530
+
531
+    def _handle_remote_files_dropped(self, remote_items: List[tuple], local_directory: str):
532
+        """Handle remote files and directories dropped onto local view."""
533
+        if not self.sftp or not self.transfer_manager:
534
+            return
535
+        
536
+        # Separate files and directories
537
+        files = []
538
+        directories = []
539
+        
540
+        for remote_path, is_dir in remote_items:
541
+            if is_dir:
542
+                dirname = os.path.basename(remote_path)
543
+                directories.append((remote_path, dirname))
544
+            else:
545
+                files.append(remote_path)
546
+        
547
+        # Handle directories
548
+        if directories:
549
+            reply = QMessageBox.question(
550
+                self,
551
+                "Download Directories",
552
+                format_status_message("Download", len(directories), "directory") + "?\n\n" +
553
+                "This will recursively download all contents.",
554
+                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
555
+                QMessageBox.StandardButton.Yes
556
+            )
557
+            
558
+            if reply == QMessageBox.StandardButton.Yes:
559
+                for remote_path, dirname in directories:
560
+                    local_path = os.path.join(local_directory, dirname)
561
+                    
562
+                    self.transfer_manager.transfer_directory(
563
+                        remote_path, local_path, is_upload=False,
564
+                        progress_callback=self._update_progress,
565
+                        complete_callback=self._transfer_complete_download,
566
+                        error_callback=self._transfer_error
567
+                    )
568
+                    
569
+                    self.progress_bar.setVisible(True)
570
+                    self.progress_bar.setValue(0)
571
+        
572
+        # Handle files
573
+        for remote_path in files:
574
+            filename = os.path.basename(remote_path)
575
+            local_path = os.path.join(local_directory, filename)
576
+            
577
+            self.transfer_manager.transfer_file(
578
+                remote_path, local_path, is_upload=False,
579
+                progress_callback=self._update_progress,
580
+                complete_callback=self._transfer_complete_download,
581
+                error_callback=self._transfer_error
582
+            )
583
+            
584
+            self.progress_bar.setVisible(True)
585
+            self.progress_bar.setValue(0)
586
+            
587
+        total_items = len(files) + len(directories)
588
+        self.status_bar.showMessage(
589
+            f"Downloading {total_items} item(s) to {local_directory}", 
590
+            AppConfig.STATUS_MESSAGE_TIMEOUT
591
+        )
592
+
593
+    def _create_directory(self, is_local: bool):
594
+        """Create a new directory."""
595
+        if is_local:
596
+            current_path = self.local_pane.model.filePath(self.local_pane.tree_view.rootIndex())
597
+        else:
598
+            current_path = self.remote_pane.model.rootPath()
599
+        
600
+        name, ok = QInputDialog.getText(
601
+            self, 
602
+            "Create Directory", 
603
+            "Directory name:"
604
+        )
605
+        
606
+        if ok and name:
607
+            try:
608
+                if is_local:
609
+                    new_path = os.path.join(current_path, name)
610
+                    os.makedirs(new_path, exist_ok=True)
611
+                    self.status_bar.showMessage(
612
+                        AppConfig.MSG_CREATE_DIR.format(name), 
613
+                        AppConfig.STATUS_MESSAGE_TIMEOUT
614
+                    )
615
+                else:
616
+                    if self.sftp:
617
+                        new_path = os.path.join(current_path, name).replace('\\', '/')
618
+                        self.sftp.mkdir(new_path)
619
+                        self.remote_pane.refresh()
620
+                        self.status_bar.showMessage(
621
+                            AppConfig.MSG_CREATE_REMOTE_DIR.format(name), 
622
+                            AppConfig.STATUS_MESSAGE_TIMEOUT
623
+                        )
624
+            except Exception as e:
625
+                QMessageBox.warning(
626
+                    self, 
627
+                    "Error", 
628
+                    f"Failed to create directory: {str(e)}"
629
+                )
630
+    
631
+    def _delete_selected(self, is_local: bool):
632
+        """Delete selected files/directories."""
633
+        if is_local:
634
+            items = self.local_pane.get_selected_items()
635
+            # Convert to expected format
636
+            items = [(path, is_dir, os.path.basename(path)) for path, is_dir in items]
637
+        else:
638
+            items = self.remote_pane.get_selected_items()
639
+        
640
+        if not items:
641
+            return
642
+        
643
+        # Confirm deletion
644
+        msg = format_status_message("Delete", len(items), "item") + "?\n\n"
645
+        for path, is_dir, name in items[:5]:  # Show first 5 items
646
+            msg += f"{'[DIR] ' if is_dir else ''}{name}\n"
647
+        if len(items) > 5:
648
+            msg += f"... and {len(items) - 5} more"
649
+        
650
+        reply = QMessageBox.question(
651
+            self,
652
+            "Confirm Delete",
653
+            msg,
654
+            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
655
+            QMessageBox.StandardButton.No
656
+        )
657
+        
658
+        # Always restore focus after the dialog, regardless of the answer
659
+        self._restore_focus()
660
+        
661
+        if reply == QMessageBox.StandardButton.Yes:
662
+            errors = []
663
+            for path, is_dir, name in items:
664
+                try:
665
+                    if is_local:
666
+                        if is_dir:
667
+                            shutil.rmtree(path)
668
+                        else:
669
+                            os.remove(path)
670
+                    else:
671
+                        if self.sftp:
672
+                            if is_dir:
673
+                                self._delete_remote_dir(path)
674
+                            else:
675
+                                self.sftp.remove(path)
676
+                except Exception as e:
677
+                    errors.append(f"{name}: {str(e)}")
678
+            
679
+            if errors:
680
+                QMessageBox.warning(
681
+                    self,
682
+                    "Delete Errors",
683
+                    "Some items could not be deleted:\n\n" + "\n".join(errors[:10])
684
+                )
685
+                # Restore focus after error dialog
686
+                self._restore_focus()
687
+            else:
688
+                self.status_bar.showMessage(
689
+                    AppConfig.MSG_DELETE_COMPLETE.format(len(items)), 
690
+                    AppConfig.STATUS_MESSAGE_TIMEOUT
691
+                )
692
+            
693
+            # Refresh views
694
+            if not is_local and self.sftp:
695
+                self.remote_pane.refresh()
696
+    
697
+    def _delete_remote_dir(self, path: str):
698
+        """Recursively delete remote directory."""
699
+        if not self.sftp:
700
+            return
701
+            
702
+        # List directory contents
703
+        for item in self.sftp.listdir_attr(path):
704
+            item_path = os.path.join(path, item.filename)
705
+            if stat.S_ISDIR(item.st_mode):
706
+                self._delete_remote_dir(item_path)
707
+            else:
708
+                self.sftp.remove(item_path)
709
+        
710
+        # Remove empty directory
711
+        self.sftp.rmdir(path)
712
+
713
+    def delete_selected(self):
714
+        """Delete selected items from whichever view has focus."""
715
+        # Determine which view has focus or has a selection
716
+        local_selection = self.local_pane.get_selected_items()
717
+        remote_selection = self.remote_pane.get_selected_items() if self.sftp else []
718
+        
719
+        if local_selection and (not remote_selection or self.local_pane.tree_view.hasFocus()):
720
+            self._delete_selected(True)
721
+        elif remote_selection:
722
+            self._delete_selected(False)
723
+        else:
724
+            QMessageBox.information(
725
+                self,
726
+                "Delete",
727
+                "Please select items to delete"
728
+            )
729
+            # Restore focus after info dialog
730
+            self._restore_focus()
src/wulftp/ui/widgets.pyadded
@@ -0,0 +1,81 @@
1
+"""Custom UI widgets for wulFTP."""
2
+
3
+from PyQt6.QtCore import Qt, pyqtSignal
4
+from PyQt6.QtGui import QDragEnterEvent, QDropEvent
5
+from PyQt6.QtWidgets import QTreeView
6
+
7
+
8
+class LocalTreeView(QTreeView):
9
+    """Custom tree view for local files that handles remote file drops."""
10
+    
11
+    remoteFilesDropped = pyqtSignal(list, str)  # remote_items (path, is_dir), local_directory
12
+    
13
+    def __init__(self, parent=None):
14
+        super().__init__(parent)
15
+        self.setAcceptDrops(True)
16
+        
17
+    def dragEnterEvent(self, event: QDragEnterEvent):
18
+        """Accept drops from remote files."""
19
+        mime_data = event.mimeData()
20
+        
21
+        # Check if this is a remote file drop
22
+        if mime_data.hasFormat("application/x-wulftp-remote"):
23
+            event.acceptProposedAction()
24
+        else:
25
+            # Let the parent handle local file drags
26
+            super().dragEnterEvent(event)
27
+    
28
+    def dragMoveEvent(self, event):
29
+        """Handle drag move events."""
30
+        mime_data = event.mimeData()
31
+        
32
+        if mime_data.hasFormat("application/x-wulftp-remote"):
33
+            # Check if we're over a directory
34
+            index = self.indexAt(event.position().toPoint())
35
+            if index.isValid():
36
+                model = self.model()
37
+                if model and model.isDir(index):
38
+                    event.acceptProposedAction()
39
+                    return
40
+            # Also accept drops on empty space (root directory)
41
+            event.acceptProposedAction()
42
+        else:
43
+            super().dragMoveEvent(event)
44
+    
45
+    def dropEvent(self, event: QDropEvent):
46
+        """Handle drops from remote files."""
47
+        mime_data = event.mimeData()
48
+        
49
+        if mime_data.hasFormat("application/x-wulftp-remote"):
50
+            # Get the target directory
51
+            index = self.indexAt(event.position().toPoint())
52
+            model = self.model()
53
+            
54
+            if index.isValid() and model:
55
+                if model.isDir(index):
56
+                    target_dir = model.filePath(index)
57
+                else:
58
+                    # Dropped on a file, use its parent directory
59
+                    target_dir = model.filePath(model.parent(index))
60
+            else:
61
+                # Dropped on empty space, use root
62
+                target_dir = model.rootPath() if model else ""
63
+            
64
+            # Get remote paths with type info
65
+            remote_data = mime_data.data("application/x-wulftp-remote").data().decode('utf-8')
66
+            remote_items = []
67
+            for line in remote_data.split('\n'):
68
+                if line.strip() and '|' in line:
69
+                    path, type_flag = line.strip().rsplit('|', 1)
70
+                    is_dir = type_flag == 'D'
71
+                    remote_items.append((path, is_dir))
72
+                elif line.strip():
73
+                    # Fallback for old format
74
+                    remote_items.append((line.strip(), False))
75
+            
76
+            if remote_items and target_dir:
77
+                self.remoteFilesDropped.emit(remote_items, target_dir)
78
+                event.acceptProposedAction()
79
+        else:
80
+            # Let parent handle local file operations
81
+            super().dropEvent(event)
src/wulftp/utils/__init__.pyadded
@@ -0,0 +1,25 @@
1
+"""Utility functions for wulFTP."""
2
+
3
+from .helpers import (
4
+    format_bytes,
5
+    format_timestamp,
6
+    format_permissions,
7
+    sanitize_path,
8
+    get_file_type_description,
9
+    count_items,
10
+    format_status_message,
11
+    is_hidden_file,
12
+    get_icon_for_file,
13
+)
14
+
15
+__all__ = [
16
+    "format_bytes",
17
+    "format_timestamp",
18
+    "format_permissions",
19
+    "sanitize_path",
20
+    "get_file_type_description",
21
+    "count_items",
22
+    "format_status_message",
23
+    "is_hidden_file",
24
+    "get_icon_for_file",
25
+]
src/wulftp/utils/helpers.pyadded
@@ -0,0 +1,326 @@
1
+"""Utility functions for wulFTP."""
2
+
3
+import os
4
+import stat
5
+from datetime import datetime
6
+from typing import Tuple, Optional
7
+
8
+
9
+def format_bytes(size: int) -> str:
10
+    """
11
+    Format bytes into human-readable string.
12
+    
13
+    Args:
14
+        size: Size in bytes
15
+        
16
+    Returns:
17
+        Human-readable size string (e.g., "1.5 MB")
18
+    """
19
+    if size < 0:
20
+        return "0 B"
21
+        
22
+    units = ["B", "KB", "MB", "GB", "TB", "PB"]
23
+    
24
+    for unit in units[:-1]:
25
+        if size < 1024.0:
26
+            return f"{size:.1f} {unit}"
27
+        size /= 1024.0
28
+    
29
+    return f"{size:.1f} {units[-1]}"
30
+
31
+
32
+def format_timestamp(timestamp: Optional[int]) -> str:
33
+    """
34
+    Format Unix timestamp into human-readable string.
35
+    
36
+    Args:
37
+        timestamp: Unix timestamp
38
+        
39
+    Returns:
40
+        Formatted date string (YYYY-MM-DD HH:MM)
41
+    """
42
+    if not timestamp:
43
+        return ""
44
+    
45
+    try:
46
+        dt = datetime.fromtimestamp(timestamp)
47
+        return dt.strftime("%Y-%m-%d %H:%M")
48
+    except (ValueError, OSError):
49
+        return ""
50
+
51
+
52
+def format_permissions(mode: int) -> str:
53
+    """
54
+    Format Unix file permissions into readable string.
55
+    
56
+    Args:
57
+        mode: File mode bits
58
+        
59
+    Returns:
60
+        Permission string (e.g., "drwxr-xr-x")
61
+    """
62
+    perms = ["-"] * 10
63
+    
64
+    # File type
65
+    if stat.S_ISDIR(mode):
66
+        perms[0] = "d"
67
+    elif stat.S_ISLNK(mode):
68
+        perms[0] = "l"
69
+    elif stat.S_ISREG(mode):
70
+        perms[0] = "-"
71
+    elif stat.S_ISBLK(mode):
72
+        perms[0] = "b"
73
+    elif stat.S_ISCHR(mode):
74
+        perms[0] = "c"
75
+    elif stat.S_ISFIFO(mode):
76
+        perms[0] = "p"
77
+    elif stat.S_ISSOCK(mode):
78
+        perms[0] = "s"
79
+    
80
+    # Permission bits
81
+    mode_bits = [
82
+        (stat.S_IRUSR, 1, "r"),
83
+        (stat.S_IWUSR, 2, "w"),
84
+        (stat.S_IXUSR, 3, "x"),
85
+        (stat.S_IRGRP, 4, "r"),
86
+        (stat.S_IWGRP, 5, "w"),
87
+        (stat.S_IXGRP, 6, "x"),
88
+        (stat.S_IROTH, 7, "r"),
89
+        (stat.S_IWOTH, 8, "w"),
90
+        (stat.S_IXOTH, 9, "x"),
91
+    ]
92
+    
93
+    for bit, pos, char in mode_bits:
94
+        if mode & bit:
95
+            perms[pos] = char
96
+    
97
+    # Special bits
98
+    if mode & stat.S_ISUID:
99
+        perms[3] = "s" if mode & stat.S_IXUSR else "S"
100
+    if mode & stat.S_ISGID:
101
+        perms[6] = "s" if mode & stat.S_IXGRP else "S"
102
+    if mode & stat.S_ISVTX:
103
+        perms[9] = "t" if mode & stat.S_IXOTH else "T"
104
+    
105
+    return "".join(perms)
106
+
107
+
108
+def sanitize_path(path: str, is_remote: bool = False) -> str:
109
+    """
110
+    Sanitize and normalize a file path.
111
+    
112
+    Args:
113
+        path: Path to sanitize
114
+        is_remote: Whether this is a remote (Unix) path
115
+        
116
+    Returns:
117
+        Sanitized path
118
+    """
119
+    if not path:
120
+        return "/"
121
+    
122
+    # Normalize the path
123
+    path = os.path.normpath(path)
124
+    
125
+    # For remote paths, always use forward slashes
126
+    if is_remote:
127
+        path = path.replace("\\", "/")
128
+        # Ensure it starts with /
129
+        if not path.startswith("/"):
130
+            path = "/" + path
131
+    
132
+    return path
133
+
134
+
135
+def get_file_type_description(filename: str, is_dir: bool = False, is_link: bool = False) -> str:
136
+    """
137
+    Get a human-readable file type description.
138
+    
139
+    Args:
140
+        filename: Name of the file
141
+        is_dir: Whether it's a directory
142
+        is_link: Whether it's a symbolic link
143
+        
144
+    Returns:
145
+        File type description (e.g., "Python File", "Folder")
146
+    """
147
+    if is_link:
148
+        return "Link"
149
+    elif is_dir:
150
+        return "Folder"
151
+    
152
+    ext = os.path.splitext(filename)[1].lower()
153
+    
154
+    if not ext:
155
+        return "File"
156
+    
157
+    # Common file type descriptions
158
+    file_types = {
159
+        # Text
160
+        ".txt": "Text File",
161
+        ".md": "Markdown File",
162
+        ".log": "Log File",
163
+        ".rst": "reStructuredText File",
164
+        
165
+        # Code
166
+        ".py": "Python File",
167
+        ".js": "JavaScript File",
168
+        ".cpp": "C++ File",
169
+        ".c": "C File",
170
+        ".java": "Java File",
171
+        ".cs": "C# File",
172
+        ".rb": "Ruby File",
173
+        ".go": "Go File",
174
+        ".rs": "Rust File",
175
+        ".php": "PHP File",
176
+        ".swift": "Swift File",
177
+        ".kt": "Kotlin File",
178
+        
179
+        # Web
180
+        ".html": "HTML File",
181
+        ".css": "CSS File",
182
+        ".xml": "XML File",
183
+        ".json": "JSON File",
184
+        ".yaml": "YAML File",
185
+        ".yml": "YAML File",
186
+        
187
+        # Images
188
+        ".jpg": "JPEG Image",
189
+        ".jpeg": "JPEG Image",
190
+        ".png": "PNG Image",
191
+        ".gif": "GIF Image",
192
+        ".bmp": "Bitmap Image",
193
+        ".svg": "SVG Image",
194
+        
195
+        # Documents
196
+        ".pdf": "PDF Document",
197
+        ".doc": "Word Document",
198
+        ".docx": "Word Document",
199
+        ".xls": "Excel Spreadsheet",
200
+        ".xlsx": "Excel Spreadsheet",
201
+        
202
+        # Archives
203
+        ".zip": "ZIP Archive",
204
+        ".tar": "TAR Archive",
205
+        ".gz": "GZIP Archive",
206
+        ".rar": "RAR Archive",
207
+        ".7z": "7-Zip Archive",
208
+        
209
+        # Media
210
+        ".mp3": "MP3 Audio",
211
+        ".wav": "WAV Audio",
212
+        ".mp4": "MP4 Video",
213
+        ".avi": "AVI Video",
214
+        ".mkv": "MKV Video",
215
+    }
216
+    
217
+    return file_types.get(ext, f"{ext[1:].upper()} File")
218
+
219
+
220
+def count_items(items: list, include_hidden: bool = True) -> Tuple[int, int]:
221
+    """
222
+    Count files and directories in a list of items.
223
+    
224
+    Args:
225
+        items: List of file items (with 'is_dir' attribute or second element as is_dir flag)
226
+        include_hidden: Whether to include hidden files in count
227
+        
228
+    Returns:
229
+        Tuple of (file_count, directory_count)
230
+    """
231
+    files = 0
232
+    dirs = 0
233
+    
234
+    for item in items:
235
+        # Handle different item formats
236
+        if hasattr(item, 'is_hidden') and item.is_hidden and not include_hidden:
237
+            continue
238
+            
239
+        # Check if it's a tuple (path, is_dir) or has is_dir attribute
240
+        if isinstance(item, tuple) and len(item) >= 2:
241
+            if item[1]:  # is_dir
242
+                dirs += 1
243
+            else:
244
+                files += 1
245
+        elif hasattr(item, 'is_dir'):
246
+            if item.is_dir:
247
+                dirs += 1
248
+            else:
249
+                files += 1
250
+    
251
+    return files, dirs
252
+
253
+
254
+def format_status_message(action: str, count: int, item_type: str = "item") -> str:
255
+    """
256
+    Format a status message with proper pluralization.
257
+    
258
+    Args:
259
+        action: Action being performed (e.g., "Uploaded", "Deleted")
260
+        count: Number of items
261
+        item_type: Type of item (e.g., "file", "directory")
262
+        
263
+    Returns:
264
+        Formatted status message
265
+    """
266
+    if count == 1:
267
+        return f"{action} 1 {item_type}"
268
+    else:
269
+        # Simple pluralization
270
+        if item_type.endswith("y"):
271
+            plural = item_type[:-1] + "ies"
272
+        elif item_type.endswith("s"):
273
+            plural = item_type + "es"
274
+        else:
275
+            plural = item_type + "s"
276
+        
277
+        return f"{action} {count} {plural}"
278
+
279
+
280
+def is_hidden_file(name: str) -> bool:
281
+    """
282
+    Check if a file/directory is hidden.
283
+    
284
+    Args:
285
+        name: File or directory name
286
+        
287
+    Returns:
288
+        True if hidden, False otherwise
289
+    """
290
+    return name.startswith(".") and name not in [".", ".."]
291
+
292
+
293
+def get_icon_for_file(filename: str, is_dir: bool = False, is_link: bool = False) -> Tuple[str, str]:
294
+    """
295
+    Get icon name and color for a file type.
296
+    
297
+    Args:
298
+        filename: Name of the file
299
+        is_dir: Whether it's a directory
300
+        is_link: Whether it's a symbolic link
301
+        
302
+    Returns:
303
+        Tuple of (icon_name, color) for use with qtawesome
304
+    """
305
+    from ..core import FileTypes
306
+    
307
+    if is_link:
308
+        return ("fa5s.link", "#6C757D")
309
+    elif is_dir:
310
+        return ("fa5s.folder", "#FFD93D")
311
+    
312
+    ext = os.path.splitext(filename)[1].lower()
313
+    category = FileTypes.get_category(ext)
314
+    
315
+    icon_map = {
316
+        "text": ("fa5s.file-alt", "#6C757D"),
317
+        "code": ("fa5s.file-code", "#28A745"),
318
+        "image": ("fa5s.file-image", "#17A2B8"),
319
+        "audio": ("fa5s.file-audio", "#FD7E14"),
320
+        "video": ("fa5s.file-video", "#DC3545"),
321
+        "archive": ("fa5s.file-archive", "#6F42C1"),
322
+        "pdf": ("fa5s.file-pdf", "#DC3545"),
323
+        "file": ("fa5s.file", "#6C757D"),
324
+    }
325
+    
326
+    return icon_map.get(category, ("fa5s.file", "#6C757D"))
src/wulftp/wulftp.pydeleted
1467 lines changed — click to load
@@ -1,1467 +0,0 @@
1
-import os
2
-import stat
3
-import sys
4
-import shutil
5
-from dataclasses import dataclass
6
-from pathlib import Path
7
-from typing import Dict, List, Optional
8
-
9
-import paramiko
10
-import qtawesome as qta
11
-from dotenv import load_dotenv
12
-from paramiko import SSHConfig
13
-from PyQt6.QtCore import (
14
-    QDir,
15
-    QModelIndex,
16
-    QObject,
17
-    QRunnable,
18
-    QSize,
19
-    Qt,
20
-    QThreadPool,
21
-    pyqtSignal,
22
-    pyqtSlot,
23
-    QMimeData,
24
-)
25
-from PyQt6.QtGui import QAction, QFileSystemModel, QIcon, QDragEnterEvent, QDropEvent
26
-from PyQt6.QtWidgets import (
27
-    QAbstractItemView,
28
-    QApplication,
29
-    QCheckBox,
30
-    QComboBox,
31
-    QHBoxLayout,
32
-    QHeaderView,
33
-    QLabel,
34
-    QLineEdit,
35
-    QMainWindow,
36
-    QMenu,
37
-    QMessageBox,
38
-    QProgressBar,
39
-    QPushButton,
40
-    QSplitter,
41
-    QStatusBar,
42
-    QToolBar,
43
-    QTreeView,
44
-    QVBoxLayout,
45
-    QWidget,
46
-    QSizePolicy,
47
-    QInputDialog,
48
-)
49
-
50
-# Load environment variables
51
-load_dotenv()
52
-
53
-
54
-@dataclass
55
-class SSHHost:
56
-    """Represents an SSH host configuration"""
57
-
58
-    alias: str
59
-    hostname: str
60
-    port: int = 22
61
-    user: str = None
62
-    key_file: str = None
63
-
64
-    def display_name(self):
65
-        if self.user:
66
-            return f"{self.alias} ({self.user}@{self.hostname})"
67
-        return f"{self.alias} ({self.hostname})"
68
-
69
-
70
-class WorkerSignals(QObject):
71
-    """Signals for thread workers"""
72
-
73
-    finished = pyqtSignal()
74
-    error = pyqtSignal(str)
75
-    progress = pyqtSignal(int)
76
-    result = pyqtSignal(object)
77
-
78
-
79
-class TransferWorker(QRunnable):
80
-    """Worker for file transfers"""
81
-
82
-    def __init__(self, sftp, source, dest, is_upload=True):
83
-        super().__init__()
84
-        self.sftp = sftp
85
-        self.source = source
86
-        self.dest = dest
87
-        self.is_upload = is_upload
88
-        self.signals = WorkerSignals()
89
-
90
-    def run(self):
91
-        try:
92
-            if self.is_upload:
93
-                self.sftp.put(self.source, self.dest, callback=self._progress_callback)
94
-            else:
95
-                self.sftp.get(self.source, self.dest, callback=self._progress_callback)
96
-            self.signals.finished.emit()
97
-        except Exception as e:
98
-            self.signals.error.emit(str(e))
99
-
100
-    def _progress_callback(self, transferred, total):
101
-        if total > 0:
102
-            progress = int((transferred / total) * 100)
103
-            self.signals.progress.emit(progress)
104
-
105
-
106
-class DirectoryTransferWorker(QRunnable):
107
-    """Worker for directory transfers"""
108
-    
109
-    def __init__(self, sftp, source_dir, dest_dir, is_upload=True):
110
-        super().__init__()
111
-        self.sftp = sftp
112
-        self.source_dir = source_dir
113
-        self.dest_dir = dest_dir
114
-        self.is_upload = is_upload
115
-        self.signals = WorkerSignals()
116
-        self.total_files = 0
117
-        self.processed_files = 0
118
-        
119
-    def run(self):
120
-        try:
121
-            if self.is_upload:
122
-                self._upload_directory(self.source_dir, self.dest_dir)
123
-            else:
124
-                self._download_directory(self.source_dir, self.dest_dir)
125
-            self.signals.finished.emit()
126
-        except Exception as e:
127
-            self.signals.error.emit(str(e))
128
-    
129
-    def _count_files(self, local_path):
130
-        """Count total files in a local directory recursively"""
131
-        count = 0
132
-        for root, dirs, files in os.walk(local_path):
133
-            count += len(files)
134
-        return count
135
-    
136
-    def _count_remote_files(self, remote_path):
137
-        """Count total files in a remote directory recursively"""
138
-        count = 0
139
-        try:
140
-            for item in self.sftp.listdir_attr(remote_path):
141
-                item_path = os.path.join(remote_path, item.filename)
142
-                if stat.S_ISDIR(item.st_mode):
143
-                    count += self._count_remote_files(item_path)
144
-                else:
145
-                    count += 1
146
-        except:
147
-            pass
148
-        return count
149
-    
150
-    def _upload_directory(self, local_path, remote_path):
151
-        """Recursively upload a directory"""
152
-        # Count total files first
153
-        if self.total_files == 0:
154
-            self.total_files = self._count_files(local_path)
155
-        
156
-        # Create remote directory
157
-        try:
158
-            self.sftp.mkdir(remote_path)
159
-        except:
160
-            # Directory might already exist
161
-            pass
162
-        
163
-        # Upload contents
164
-        for item in os.listdir(local_path):
165
-            local_item = os.path.join(local_path, item)
166
-            remote_item = os.path.join(remote_path, item).replace('\\', '/')
167
-            
168
-            if os.path.isdir(local_item):
169
-                self._upload_directory(local_item, remote_item)
170
-            else:
171
-                # Upload file
172
-                self.sftp.put(local_item, remote_item)
173
-                self.processed_files += 1
174
-                if self.total_files > 0:
175
-                    progress = int((self.processed_files / self.total_files) * 100)
176
-                    self.signals.progress.emit(progress)
177
-    
178
-    def _download_directory(self, remote_path, local_path):
179
-        """Recursively download a directory"""
180
-        # Count total files first
181
-        if self.total_files == 0:
182
-            self.total_files = self._count_remote_files(remote_path)
183
-        
184
-        # Create local directory
185
-        os.makedirs(local_path, exist_ok=True)
186
-        
187
-        # Download contents
188
-        for item in self.sftp.listdir_attr(remote_path):
189
-            remote_item = os.path.join(remote_path, item.filename).replace('\\', '/')
190
-            local_item = os.path.join(local_path, item.filename)
191
-            
192
-            if stat.S_ISDIR(item.st_mode):
193
-                self._download_directory(remote_item, local_item)
194
-            else:
195
-                # Download file
196
-                self.sftp.get(remote_item, local_item)
197
-                self.processed_files += 1
198
-                if self.total_files > 0:
199
-                    progress = int((self.processed_files / self.total_files) * 100)
200
-                    self.signals.progress.emit(progress)
201
-
202
-
203
-class LocalTreeView(QTreeView):
204
-    """Custom tree view for local files that handles remote file drops"""
205
-    
206
-    remoteFilesDropped = pyqtSignal(list, str)  # remote_items (path, is_dir), local_directory
207
-    
208
-    def __init__(self, parent=None):
209
-        super().__init__(parent)
210
-        self.setAcceptDrops(True)
211
-        
212
-    def dragEnterEvent(self, event: QDragEnterEvent):
213
-        """Accept drops from remote files"""
214
-        mime_data = event.mimeData()
215
-        
216
-        # Check if this is a remote file drop
217
-        if mime_data.hasFormat("application/x-wulftp-remote"):
218
-            event.acceptProposedAction()
219
-        else:
220
-            # Let the parent handle local file drags
221
-            super().dragEnterEvent(event)
222
-    
223
-    def dragMoveEvent(self, event):
224
-        """Handle drag move events"""
225
-        mime_data = event.mimeData()
226
-        
227
-        if mime_data.hasFormat("application/x-wulftp-remote"):
228
-            # Check if we're over a directory
229
-            index = self.indexAt(event.position().toPoint())
230
-            if index.isValid():
231
-                model = self.model()
232
-                if model and model.isDir(index):
233
-                    event.acceptProposedAction()
234
-                    return
235
-            # Also accept drops on empty space (root directory)
236
-            event.acceptProposedAction()
237
-        else:
238
-            super().dragMoveEvent(event)
239
-    
240
-    def dropEvent(self, event: QDropEvent):
241
-        """Handle drops from remote files"""
242
-        mime_data = event.mimeData()
243
-        
244
-        if mime_data.hasFormat("application/x-wulftp-remote"):
245
-            # Get the target directory
246
-            index = self.indexAt(event.position().toPoint())
247
-            model = self.model()
248
-            
249
-            if index.isValid() and model:
250
-                if model.isDir(index):
251
-                    target_dir = model.filePath(index)
252
-                else:
253
-                    # Dropped on a file, use its parent directory
254
-                    target_dir = model.filePath(model.parent(index))
255
-            else:
256
-                # Dropped on empty space, use root
257
-                target_dir = model.rootPath() if model else ""
258
-            
259
-            # Get remote paths with type info
260
-            remote_data = mime_data.data("application/x-wulftp-remote").data().decode('utf-8')
261
-            remote_items = []
262
-            for line in remote_data.split('\n'):
263
-                if line.strip() and '|' in line:
264
-                    path, type_flag = line.strip().rsplit('|', 1)
265
-                    is_dir = type_flag == 'D'
266
-                    remote_items.append((path, is_dir))
267
-                elif line.strip():
268
-                    # Fallback for old format
269
-                    remote_items.append((line.strip(), False))
270
-            
271
-            if remote_items and target_dir:
272
-                self.remoteFilesDropped.emit(remote_items, target_dir)
273
-                event.acceptProposedAction()
274
-        else:
275
-            # Let parent handle local file operations
276
-            super().dropEvent(event)
277
-
278
-
279
-class WulFTPClient(QMainWindow):
280
-    def __init__(self):
281
-        super().__init__()
282
-        self.ssh = None
283
-        self.sftp = None
284
-        self.threadpool = QThreadPool()
285
-        self.ssh_hosts = self._load_ssh_config()
286
-        
287
-        # Import the model here to avoid circular imports
288
-        from .remote_model import RemoteFileSystemModel
289
-        self.remote_model = RemoteFileSystemModel()
290
-        
291
-        self.init_ui()
292
-
293
-    def _load_ssh_config(self) -> Dict[str, SSHHost]:
294
-        """Load SSH hosts from ~/.ssh/config"""
295
-        hosts = {}
296
-        config_path = Path.home() / ".ssh" / "config"
297
-
298
-        if config_path.exists():
299
-            config = SSHConfig()
300
-            with open(config_path) as f:
301
-                config.parse(f)
302
-
303
-            for host in config.get_hostnames():
304
-                if host == "*":
305
-                    continue
306
-
307
-                cfg = config.lookup(host)
308
-                hosts[host] = SSHHost(
309
-                    alias=host,
310
-                    hostname=cfg.get("hostname", host),
311
-                    port=int(cfg.get("port", 22)),
312
-                    user=cfg.get("user"),
313
-                    key_file=cfg.get("identityfile", [None])[0],
314
-                )
315
-
316
-        # Add custom hosts from environment
317
-        if os.getenv("WULFTP_HOST"):
318
-            hosts["env_default"] = SSHHost(
319
-                alias="Default",
320
-                hostname=os.getenv("WULFTP_HOST"),
321
-                port=int(os.getenv("WULFTP_PORT", "22")),
322
-                user=os.getenv("WULFTP_USER"),
323
-                key_file=os.getenv("WULFTP_KEY"),
324
-            )
325
-
326
-        return hosts
327
-
328
-    def init_ui(self):
329
-        self.setWindowTitle("wulFTP - Family Backup Tool")
330
-        self.setGeometry(100, 100, 1200, 800)
331
-
332
-        # Central widget
333
-        central = QWidget()
334
-        self.setCentralWidget(central)
335
-
336
-        # Main layout - no margins for maximum space
337
-        layout = QVBoxLayout(central)
338
-        layout.setContentsMargins(0, 0, 0, 0)
339
-        layout.setSpacing(0)
340
-
341
-        # Status bar (create early so conn_status is available)
342
-        self.status_bar = QStatusBar()
343
-        self.setStatusBar(self.status_bar)
344
-
345
-        # Progress bar (hidden by default)
346
-        self.progress_bar = QProgressBar()
347
-        self.progress_bar.setVisible(False)
348
-        self.status_bar.addPermanentWidget(self.progress_bar)
349
-
350
-        # Connection status
351
-        self.conn_status = QLabel("Disconnected")
352
-        self.status_bar.addPermanentWidget(self.conn_status)
353
-
354
-        # Create toolbar with connection controls
355
-        self._create_toolbar()
356
-
357
-        # Create splitter for two-pane view - this is the main content
358
-        splitter = QSplitter(Qt.Orientation.Horizontal)
359
-        splitter.setContentsMargins(0, 0, 0, 0)
360
-
361
-        # Local pane
362
-        self.local_pane = self._create_file_pane("Local Files", is_local=True)
363
-        splitter.addWidget(self.local_pane)
364
-
365
-        # Remote pane
366
-        self.remote_pane = self._create_file_pane("Remote Files", is_local=False)
367
-        self.remote_pane.setEnabled(False)
368
-        splitter.addWidget(self.remote_pane)
369
-
370
-        # Set initial splitter sizes
371
-        splitter.setSizes([600, 600])
372
-        
373
-        # Add splitter to main layout
374
-        layout.addWidget(splitter)
375
-        
376
-        # Initial button state update
377
-        self._update_button_states()
378
-
379
-    def _create_toolbar(self):
380
-        """Create main toolbar"""
381
-        toolbar = QToolBar()
382
-        toolbar.setMovable(False)  # Keep toolbar in place
383
-        self.addToolBar(toolbar)
384
-
385
-        # Connection controls in toolbar
386
-        # Host dropdown
387
-        toolbar.addWidget(QLabel(" Host: "))
388
-        self.host_combo = QComboBox()
389
-        self.host_combo.setMinimumWidth(250)
390
-        self.host_combo.setMaximumWidth(350)
391
-
392
-        # Populate hosts
393
-        for host in self.ssh_hosts.values():
394
-            self.host_combo.addItem(host.display_name(), host)
395
-
396
-        # Add custom option
397
-        self.host_combo.addItem("Custom...", None)
398
-        self.host_combo.currentIndexChanged.connect(self._on_host_changed)
399
-        toolbar.addWidget(self.host_combo)
400
-
401
-        # Custom host fields (hidden by default)
402
-        self.custom_host = QLineEdit()
403
-        self.custom_host.setPlaceholderText("hostname")
404
-        self.custom_host.setVisible(False)
405
-        self.custom_host.setMaximumWidth(150)
406
-        toolbar.addWidget(self.custom_host)
407
-
408
-        self.custom_user = QLineEdit()
409
-        self.custom_user.setPlaceholderText("username")
410
-        self.custom_user.setVisible(False)
411
-        self.custom_user.setMaximumWidth(100)
412
-        toolbar.addWidget(self.custom_user)
413
-
414
-        # Connect button
415
-        self.connect_btn = QPushButton()
416
-        self.connect_btn.setIcon(qta.icon("fa5s.plug", color="#4CAF50"))
417
-        self.connect_btn.setText("Connect")
418
-        self.connect_btn.clicked.connect(self.toggle_connection)
419
-        toolbar.addWidget(self.connect_btn)
420
-
421
-        # Connection indicator
422
-        self.conn_indicator = QLabel()
423
-        self._update_connection_status(False)
424
-        toolbar.addWidget(self.conn_indicator)
425
-
426
-        toolbar.addSeparator()
427
-
428
-        # Upload action
429
-        upload_action = QAction(
430
-            qta.icon("fa5s.upload", color="#4CAF50"), "Upload Selected", self
431
-        )
432
-        upload_action.triggered.connect(self.upload_selected)
433
-        upload_action.setEnabled(False)
434
-        toolbar.addAction(upload_action)
435
-        self.upload_action = upload_action
436
-
437
-        # Download action
438
-        download_action = QAction(
439
-            qta.icon("fa5s.download", color="#2196F3"), "Download Selected", self
440
-        )
441
-        download_action.triggered.connect(self.download_selected)
442
-        download_action.setEnabled(False)
443
-        toolbar.addAction(download_action)
444
-        self.download_action = download_action
445
-
446
-        toolbar.addSeparator()
447
-
448
-        # Delete action
449
-        delete_action = QAction(
450
-            qta.icon("fa5s.trash", color="#F44336"), "Delete Selected", self
451
-        )
452
-        delete_action.triggered.connect(self.delete_selected)
453
-        delete_action.setEnabled(True)  # Always enabled
454
-        toolbar.addAction(delete_action)
455
-        self.delete_action = delete_action
456
-
457
-        toolbar.addSeparator()
458
-
459
-        # Refresh action
460
-        refresh_action = QAction(
461
-            qta.icon("fa5s.sync", color="#FF9800"), "Refresh", self
462
-        )
463
-        refresh_action.triggered.connect(self.refresh_views)
464
-        toolbar.addAction(refresh_action)
465
-        
466
-        # Add spacer to push settings to the right
467
-        spacer = QWidget()
468
-        spacer.setSizePolicy(
469
-            QSizePolicy.Policy.Expanding,
470
-            QSizePolicy.Policy.Expanding
471
-        )
472
-        toolbar.addWidget(spacer)
473
-        
474
-        # Settings actions
475
-        self.show_hidden_action = QAction("Show Hidden Files", self)
476
-        self.show_hidden_action.setCheckable(True)
477
-        self.show_hidden_action.setChecked(False)
478
-        self.show_hidden_action.toggled.connect(self._toggle_hidden_files)
479
-        toolbar.addAction(self.show_hidden_action)
480
-        
481
-        self.show_details_action = QAction("Show Full Details", self)
482
-        self.show_details_action.setCheckable(True)
483
-        self.show_details_action.setChecked(False)
484
-        self.show_details_action.toggled.connect(self._toggle_full_details)
485
-        toolbar.addAction(self.show_details_action)
486
-
487
-    def _create_connection_bar(self):
488
-        """Create connection controls"""
489
-        widget = QWidget()
490
-        layout = QHBoxLayout(widget)
491
-        layout.setContentsMargins(8, 4, 8, 4)
492
-
493
-        # Host dropdown
494
-        layout.addWidget(QLabel("Host:"))
495
-        self.host_combo = QComboBox()
496
-        self.host_combo.setMinimumWidth(250)
497
-
498
-        # Populate hosts
499
-        for host in self.ssh_hosts.values():
500
-            self.host_combo.addItem(host.display_name(), host)
501
-
502
-        # Add custom option
503
-        self.host_combo.addItem("Custom...", None)
504
-        self.host_combo.currentIndexChanged.connect(self._on_host_changed)
505
-        layout.addWidget(self.host_combo)
506
-
507
-        # Custom host fields (hidden by default)
508
-        self.custom_host = QLineEdit()
509
-        self.custom_host.setPlaceholderText("hostname")
510
-        self.custom_host.setVisible(False)
511
-        layout.addWidget(self.custom_host)
512
-
513
-        self.custom_user = QLineEdit()
514
-        self.custom_user.setPlaceholderText("username")
515
-        self.custom_user.setVisible(False)
516
-        layout.addWidget(self.custom_user)
517
-
518
-        # Connect button
519
-        self.connect_btn = QPushButton("Connect")
520
-        self.connect_btn.clicked.connect(self.toggle_connection)
521
-        layout.addWidget(self.connect_btn)
522
-
523
-        # Connection indicator
524
-        self.conn_indicator = QLabel()
525
-        self._update_connection_status(False)
526
-        layout.addWidget(self.conn_indicator)
527
-
528
-        layout.addStretch()
529
-        return widget
530
-
531
-    def _create_file_pane(self, title: str, is_local: bool):
532
-        """Create a file browser pane"""
533
-        widget = QWidget()
534
-        layout = QVBoxLayout(widget)
535
-        layout.setContentsMargins(5, 5, 5, 5)
536
-        layout.setSpacing(5)
537
-
538
-        # Header with navigation
539
-        header = QWidget()
540
-        header_layout = QHBoxLayout(header)
541
-        header_layout.setContentsMargins(0, 0, 0, 0)
542
-
543
-        # Title
544
-        title_label = QLabel(title)
545
-        title_label.setStyleSheet("font-weight: bold; font-size: 14px;")
546
-        header_layout.addWidget(title_label)
547
-
548
-        header_layout.addStretch()
549
-
550
-        # Navigation buttons
551
-        up_btn = QPushButton()
552
-        up_btn.setIcon(qta.icon("fa5s.arrow-up"))
553
-        up_btn.setMaximumSize(30, 30)
554
-        up_btn.setToolTip("Go up one directory")
555
-        header_layout.addWidget(up_btn)
556
-
557
-        home_btn = QPushButton()
558
-        home_btn.setIcon(qta.icon("fa5s.home"))
559
-        home_btn.setMaximumSize(30, 30)
560
-        home_btn.setToolTip("Go to home directory")
561
-        header_layout.addWidget(home_btn)
562
-
563
-        # Create directory button
564
-        create_dir_btn = QPushButton()
565
-        create_dir_btn.setIcon(qta.icon("fa5s.folder-plus", color="#FF9800"))
566
-        create_dir_btn.setMaximumSize(30, 30)
567
-        create_dir_btn.setToolTip("Create new directory")
568
-        create_dir_btn.clicked.connect(lambda: self._create_directory(is_local))
569
-        header_layout.addWidget(create_dir_btn)
570
-        
571
-        # Store reference to remote create dir button for enabling/disabling
572
-        if not is_local:
573
-            self.remote_create_dir_btn = create_dir_btn
574
-
575
-        layout.addWidget(header)
576
-
577
-        # Path display
578
-        path_edit = QLineEdit()
579
-        path_edit.setReadOnly(True)
580
-        path_edit.setStyleSheet("""
581
-            QLineEdit { 
582
-                background-color: #2b2b2b; 
583
-                color: #ffffff;
584
-                border: 1px solid #555555;
585
-                padding: 4px;
586
-                font-family: monospace;
587
-            }
588
-        """)
589
-        layout.addWidget(path_edit)
590
-
591
-        # File view
592
-        if is_local:
593
-            # Local file system model
594
-            model = QFileSystemModel()
595
-            model.setRootPath(QDir.homePath())
596
-
597
-            # Use custom tree view for local files
598
-            view = LocalTreeView()
599
-            view.setModel(model)
600
-            view.setRootIndex(model.index(QDir.homePath()))
601
-            view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
602
-            
603
-            # Enable drag from local view
604
-            view.setDragEnabled(True)
605
-            view.setDefaultDropAction(Qt.DropAction.CopyAction)
606
-            
607
-            # Drop is handled by LocalTreeView
608
-            view.setDropIndicatorShown(True)
609
-            
610
-            # Connect remote file drop signal
611
-            view.remoteFilesDropped.connect(self._handle_remote_files_dropped)
612
-
613
-            # Configure columns
614
-            view.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
615
-            view.setColumnWidth(1, 100)  # Size
616
-            view.setColumnWidth(2, 120)  # Type
617
-            view.setColumnWidth(3, 120)  # Date
618
-
619
-            # Store references
620
-            self.local_model = model
621
-            self.local_view = view
622
-            self.local_path = path_edit
623
-            self.local_path.setText(QDir.homePath())
624
-
625
-            # Connect navigation
626
-            up_btn.clicked.connect(lambda: self._navigate_up(True))
627
-            home_btn.clicked.connect(lambda: self._navigate_home(True))
628
-            view.doubleClicked.connect(lambda idx: self._on_local_double_click(idx))
629
-            
630
-            # Connect selection change to update button states
631
-            view.selectionModel().selectionChanged.connect(self._update_button_states)
632
-
633
-        else:
634
-            # Remote file list with the new model
635
-            view = QTreeView()
636
-            view.setModel(self.remote_model)
637
-            view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
638
-            
639
-            # Enable drag from remote view and drop onto it
640
-            view.setDragEnabled(True)
641
-            view.setAcceptDrops(True)
642
-            view.setDefaultDropAction(Qt.DropAction.CopyAction)
643
-            view.setDropIndicatorShown(True)
644
-            
645
-            # Configure columns
646
-            view.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
647
-            view.setColumnWidth(1, 100)  # Size
648
-            view.setColumnWidth(2, 100)  # Type
649
-            view.setColumnWidth(3, 120)  # Modified
650
-            
651
-            # Performance optimizations
652
-            view.setUniformRowHeights(True)
653
-            view.setAlternatingRowColors(True)
654
-            
655
-            # Store references
656
-            self.remote_view = view
657
-            self.remote_path = path_edit
658
-            self.remote_cwd = "/"
659
-            
660
-            # Connect model signals
661
-            self.remote_model.directoryLoaded.connect(lambda path: self.remote_path.setText(path))
662
-            self.remote_model.errorOccurred.connect(lambda err: QMessageBox.warning(self, "Error", err))
663
-            self.remote_model.filesDropped.connect(self._handle_files_dropped)
664
-            
665
-            # Connect navigation
666
-            up_btn.clicked.connect(lambda: self._navigate_up(False))
667
-            home_btn.clicked.connect(lambda: self._navigate_home(False))
668
-            view.doubleClicked.connect(lambda idx: self._on_remote_double_click(idx))
669
-            
670
-            # Connect single click to update path
671
-            view.clicked.connect(lambda idx: self._on_remote_clicked(idx))
672
-            
673
-            # Track expansion state
674
-            view.expanded.connect(self._on_remote_expanded)
675
-            view.collapsed.connect(self._on_remote_collapsed)
676
-            
677
-            # Connect selection change to update button states
678
-            view.selectionModel().selectionChanged.connect(self._update_button_states)
679
-
680
-        # Context menu
681
-        view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
682
-        view.customContextMenuRequested.connect(
683
-            lambda pos: self._show_context_menu(pos, is_local)
684
-        )
685
-
686
-        layout.addWidget(view)
687
-        return widget
688
-
689
-    def _on_host_changed(self, index):
690
-        """Handle host selection change"""
691
-        host = self.host_combo.currentData()
692
-        is_custom = host is None
693
-
694
-        self.custom_host.setVisible(is_custom)
695
-        self.custom_user.setVisible(is_custom)
696
-
697
-    def _update_connection_status(self, connected: bool):
698
-        """Update connection indicators"""
699
-        if connected:
700
-            icon = qta.icon("fa5s.link", color="#4CAF50")
701
-            self.conn_status.setText("Connected")
702
-            self.connect_btn.setText("Disconnect")
703
-            self.connect_btn.setIcon(qta.icon("fa5s.unlink", color="#F44336"))
704
-        else:
705
-            icon = qta.icon("fa5s.unlink", color="#F44336")
706
-            self.conn_status.setText("Disconnected")
707
-            self.connect_btn.setText("Connect")
708
-            self.connect_btn.setIcon(qta.icon("fa5s.plug", color="#4CAF50"))
709
-
710
-        self.conn_indicator.setPixmap(icon.pixmap(24, 24))
711
-        
712
-        # Only update these if they exist (they might not during initialization)
713
-        if hasattr(self, 'upload_action'):
714
-            self.upload_action.setEnabled(connected)
715
-        if hasattr(self, 'download_action'):
716
-            self.download_action.setEnabled(connected)
717
-        # Delete action is always enabled (not dependent on connection)
718
-        if hasattr(self, 'remote_pane'):
719
-            self.remote_pane.setEnabled(connected)
720
-
721
-    def toggle_connection(self):
722
-        """Connect or disconnect from server"""
723
-        if self.sftp:
724
-            self.disconnect()
725
-        else:
726
-            self.connect()
727
-
728
-    def connect(self):
729
-        """Establish SFTP connection"""
730
-        # Store current focus widget
731
-        focus_widget = QApplication.focusWidget()
732
-        
733
-        try:
734
-            host_data = self.host_combo.currentData()
735
-
736
-            if host_data:
737
-                # Use predefined host
738
-                hostname = host_data.hostname
739
-                port = host_data.port
740
-                username = host_data.user
741
-                key_file = host_data.key_file
742
-            else:
743
-                # Use custom values
744
-                hostname = self.custom_host.text()
745
-                username = self.custom_user.text()
746
-                port = 22
747
-                key_file = None
748
-
749
-            # Create SSH client
750
-            self.ssh = paramiko.SSHClient()
751
-            self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
752
-
753
-            # Try to connect with various authentication methods
754
-            connect_kwargs = {
755
-                "hostname": hostname,
756
-                "port": port,
757
-                "username": username,
758
-                "timeout": 10,
759
-            }
760
-
761
-            if key_file and os.path.exists(os.path.expanduser(key_file)):
762
-                # Try different key types
763
-                key_path = os.path.expanduser(key_file)
764
-                try:
765
-                    # Try to load key with auto-detection
766
-                    connect_kwargs["key_filename"] = key_path
767
-                except:
768
-                    pass
769
-
770
-            # Allow password fallback
771
-            connect_kwargs["look_for_keys"] = True
772
-            connect_kwargs["allow_agent"] = True
773
-
774
-            self.ssh.connect(**connect_kwargs)
775
-            self.sftp = self.ssh.open_sftp()
776
-
777
-            # Update remote model with SFTP connection
778
-            self.remote_model.set_sftp(self.sftp)
779
-            
780
-            # Update UI
781
-            self._update_connection_status(True)
782
-
783
-            self.status_bar.showMessage(f"Connected to {hostname}", 3000)
784
-            
785
-            # Restore focus
786
-            if focus_widget:
787
-                focus_widget.setFocus()
788
-
789
-        except Exception as e:
790
-            QMessageBox.critical(
791
-                self, 
792
-                "Connection Error", 
793
-                str(e),
794
-                QMessageBox.StandardButton.Ok,
795
-                QMessageBox.StandardButton.Ok
796
-            )
797
-            self._update_connection_status(False)
798
-            
799
-            # Restore focus even on error
800
-            if focus_widget:
801
-                focus_widget.setFocus()
802
-
803
-    def disconnect(self):
804
-        """Close SFTP connection"""
805
-        if self.sftp:
806
-            self.sftp.close()
807
-            self.sftp = None
808
-        if self.ssh:
809
-            self.ssh.close()
810
-            self.ssh = None
811
-
812
-        # Clear remote model
813
-        self.remote_model.set_sftp(None)
814
-        
815
-        self._update_connection_status(False)
816
-        self.status_bar.showMessage("Disconnected", 3000)
817
-
818
-    def refresh_views(self):
819
-        """Refresh both file views"""
820
-        # Local is automatically updated by QFileSystemModel
821
-        if self.sftp and self.remote_model:
822
-            self.remote_model.refresh(preserve_state=True)
823
-
824
-    def _toggle_hidden_files(self, checked: bool):
825
-        """Toggle showing hidden files in remote view"""
826
-        self.remote_model.set_show_hidden(checked)
827
-        
828
-    def _toggle_full_details(self, checked: bool):
829
-        """Toggle showing full details (permissions) in remote view"""
830
-        self.remote_model.set_show_full_details(checked)
831
-        # Adjust column visibility
832
-        if checked:
833
-            self.remote_view.showColumn(4)  # Permissions column
834
-        else:
835
-            self.remote_view.hideColumn(4)
836
-
837
-    def _navigate_up(self, is_local: bool):
838
-        """Navigate up one directory"""
839
-        if is_local:
840
-            current = self.local_view.rootIndex()
841
-            parent = self.local_model.parent(current)
842
-            if parent.isValid():
843
-                self.local_view.setRootIndex(parent)
844
-                self.local_path.setText(self.local_model.filePath(parent))
845
-        else:
846
-            # For remote, we navigate in the tree view
847
-            current_path = self.remote_path.text()
848
-            if current_path and current_path != "/":
849
-                parent_path = os.path.dirname(current_path)
850
-                # Find the parent item in the model
851
-                parent_item = self.remote_model._find_item_by_path(parent_path)
852
-                if parent_item:
853
-                    parent_idx = self.remote_model._get_index_for_item(parent_item)
854
-                    # Expand to show it
855
-                    self.remote_view.expand(parent_idx)
856
-                    # Update path display
857
-                    self.remote_path.setText(parent_path)
858
-
859
-    def _navigate_home(self, is_local: bool):
860
-        """Navigate to home directory"""
861
-        if is_local:
862
-            home_index = self.local_model.index(QDir.homePath())
863
-            self.local_view.setRootIndex(home_index)
864
-            self.local_path.setText(QDir.homePath())
865
-        else:
866
-            # Just update the path display for remote
867
-            self.remote_path.setText("/")
868
-
869
-    def _on_remote_double_click(self, index: QModelIndex):
870
-        """Handle double-click on remote file"""
871
-        if self.remote_model.isDir(index):
872
-            # Don't change the view root, just expand/collapse
873
-            if self.remote_view.isExpanded(index):
874
-                self.remote_view.collapse(index)
875
-            else:
876
-                self.remote_view.expand(index)
877
-            
878
-            # Update path display
879
-            path = self.remote_model.filePath(index)
880
-            self.remote_path.setText(path)
881
-
882
-    def _on_remote_clicked(self, index: QModelIndex):
883
-        """Handle single click on remote file"""
884
-        # Update path display when item is selected
885
-        path = self.remote_model.filePath(index)
886
-        if path:
887
-            self.remote_path.setText(path)
888
-
889
-    def _on_local_double_click(self, index: QModelIndex):
890
-        """Handle double-click on local file"""
891
-        if self.local_model.isDir(index):
892
-            self.local_view.setRootIndex(index)
893
-            self.local_path.setText(self.local_model.filePath(index))
894
-
895
-    def _update_button_states(self):
896
-        """Update button states based on current selection"""
897
-        # Check if anything is selected in either view
898
-        local_has_selection = bool(self.local_view.selectedIndexes())
899
-        remote_has_selection = bool(self.remote_view.selectedIndexes()) if self.sftp else False
900
-        
901
-        has_any_selection = local_has_selection or remote_has_selection
902
-        
903
-        # Update delete button - make it look disabled when nothing is selected
904
-        if hasattr(self, 'delete_action'):
905
-            if has_any_selection:
906
-                self.delete_action.setIcon(qta.icon("fa5s.trash", color="#F44336"))
907
-            else:
908
-                self.delete_action.setIcon(qta.icon("fa5s.trash", color="#888888"))
909
-
910
-    def _on_remote_expanded(self, index: QModelIndex):
911
-        """Track when a directory is expanded"""
912
-        path = self.remote_model.filePath(index)
913
-        if path:
914
-            self.remote_model.mark_expanded(path)
915
-    
916
-    def _on_remote_collapsed(self, index: QModelIndex):
917
-        """Track when a directory is collapsed"""
918
-        path = self.remote_model.filePath(index)
919
-        if path:
920
-            self.remote_model.mark_collapsed(path)
921
-
922
-    def _show_context_menu(self, pos, is_local: bool):
923
-        """Show context menu for file operations"""
924
-        menu = QMenu()
925
-        view = self.local_view if is_local else self.remote_view
926
-        
927
-        # Get selected items
928
-        selection = view.selectedIndexes()
929
-        has_selection = len(selection) > 0
930
-
931
-        if self.sftp:  # Only show transfer options when connected
932
-            if is_local:
933
-                upload_action = menu.addAction(
934
-                    qta.icon("fa5s.upload", color="#4CAF50"), "Upload"
935
-                )
936
-                upload_action.triggered.connect(self.upload_selected)
937
-                upload_action.setEnabled(has_selection)
938
-            else:
939
-                download_action = menu.addAction(
940
-                    qta.icon("fa5s.download", color="#2196F3"), "Download"
941
-                )
942
-                download_action.triggered.connect(self.download_selected)
943
-                download_action.setEnabled(has_selection)
944
-
945
-        menu.addSeparator()
946
-        
947
-        # Create directory action
948
-        create_dir_action = menu.addAction(
949
-            qta.icon("fa5s.folder-plus", color="#FF9800"), "Create Directory"
950
-        )
951
-        create_dir_action.triggered.connect(lambda: self._create_directory(is_local))
952
-        
953
-        # Delete action
954
-        if has_selection:
955
-            delete_action = menu.addAction(
956
-                qta.icon("fa5s.trash", color="#F44336"), "Delete"
957
-            )
958
-            delete_action.triggered.connect(lambda: self._delete_selected(is_local))
959
-        
960
-        menu.addSeparator()
961
-
962
-        refresh_action = menu.addAction(qta.icon("fa5s.sync"), "Refresh")
963
-        refresh_action.triggered.connect(self.refresh_views)
964
-
965
-        # Show menu at cursor position
966
-        menu.exec(view.mapToGlobal(pos))
967
-
968
-    def upload_selected(self):
969
-        """Upload selected local files and directories"""
970
-        if not self.sftp:
971
-            return
972
-
973
-        selection = self.local_view.selectedIndexes()
974
-        if not selection:
975
-            return
976
-
977
-        # Get unique file/directory paths (avoiding duplicates from multiple columns)
978
-        items = []
979
-        for index in selection:
980
-            if index.column() == 0:  # Only process name column
981
-                path = self.local_model.filePath(index)
982
-                is_dir = self.local_model.isDir(index)
983
-                items.append((path, is_dir))
984
-
985
-        # Separate files and directories
986
-        files = [path for path, is_dir in items if not is_dir]
987
-        directories = [path for path, is_dir in items if is_dir]
988
-        
989
-        # Handle directories first
990
-        if directories:
991
-            reply = QMessageBox.question(
992
-                self,
993
-                "Upload Directories",
994
-                f"Upload {len(directories)} director{'y' if len(directories) == 1 else 'ies'}?\n\n" +
995
-                "This will recursively upload all contents.",
996
-                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
997
-                QMessageBox.StandardButton.Yes
998
-            )
999
-            
1000
-            if reply == QMessageBox.StandardButton.Yes:
1001
-                for dir_path in directories:
1002
-                    dirname = os.path.basename(dir_path)
1003
-                    remote_base = self.remote_path.text() or "/"
1004
-                    remote_path = os.path.join(remote_base, dirname).replace('\\', '/')
1005
-                    
1006
-                    worker = DirectoryTransferWorker(self.sftp, dir_path, remote_path, is_upload=True)
1007
-                    
1008
-                    # Connect signals
1009
-                    worker.signals.progress.connect(self._update_progress)
1010
-                    worker.signals.finished.connect(self._transfer_complete)
1011
-                    worker.signals.error.connect(self._transfer_error)
1012
-                    
1013
-                    # Show progress bar
1014
-                    self.progress_bar.setVisible(True)
1015
-                    self.progress_bar.setValue(0)
1016
-                    
1017
-                    # Start transfer
1018
-                    self.threadpool.start(worker)
1019
-                    
1020
-                    self.status_bar.showMessage(f"Uploading directory: {dirname}", 3000)
1021
-        
1022
-        # Handle files
1023
-        if files:
1024
-            self._transfer_files(files, is_upload=True)
1025
-
1026
-    def download_selected(self):
1027
-        """Download selected remote files and directories"""
1028
-        if not self.sftp:
1029
-            return
1030
-
1031
-        selection = self.remote_view.selectedIndexes()
1032
-        if not selection:
1033
-            return
1034
-
1035
-        # Get unique file/directory paths (avoiding duplicates from multiple columns)
1036
-        items = []
1037
-        for index in selection:
1038
-            if index.column() == 0:  # Only process name column
1039
-                file_info = self.remote_model.fileInfo(index)
1040
-                if file_info:
1041
-                    items.append((file_info.full_path, file_info.is_dir, file_info.name))
1042
-
1043
-        # Separate files and directories
1044
-        files = [path for path, is_dir, name in items if not is_dir]
1045
-        directories = [(path, name) for path, is_dir, name in items if is_dir]
1046
-        
1047
-        # Handle directories first
1048
-        if directories:
1049
-            reply = QMessageBox.question(
1050
-                self,
1051
-                "Download Directories",
1052
-                f"Download {len(directories)} director{'y' if len(directories) == 1 else 'ies'}?\n\n" +
1053
-                "This will recursively download all contents.",
1054
-                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1055
-                QMessageBox.StandardButton.Yes
1056
-            )
1057
-            
1058
-            if reply == QMessageBox.StandardButton.Yes:
1059
-                for dir_path, dirname in directories:
1060
-                    local_base = self.local_model.filePath(self.local_view.rootIndex())
1061
-                    local_path = os.path.join(local_base, dirname)
1062
-                    
1063
-                    worker = DirectoryTransferWorker(self.sftp, dir_path, local_path, is_upload=False)
1064
-                    
1065
-                    # Connect signals
1066
-                    worker.signals.progress.connect(self._update_progress)
1067
-                    worker.signals.finished.connect(self._transfer_complete_download)
1068
-                    worker.signals.error.connect(self._transfer_error)
1069
-                    
1070
-                    # Show progress bar
1071
-                    self.progress_bar.setVisible(True)
1072
-                    self.progress_bar.setValue(0)
1073
-                    
1074
-                    # Start transfer
1075
-                    self.threadpool.start(worker)
1076
-                    
1077
-                    self.status_bar.showMessage(f"Downloading directory: {dirname}", 3000)
1078
-
1079
-        # Handle files
1080
-        if files:
1081
-            self._transfer_files(files, is_upload=False)
1082
-
1083
-    def _transfer_files(self, files: List[str], is_upload: bool):
1084
-        """Transfer files with progress tracking"""
1085
-        for file_path in files:
1086
-            filename = os.path.basename(file_path)
1087
-
1088
-            if is_upload:
1089
-                # Get current remote directory from path display
1090
-                remote_base = self.remote_path.text() or "/"
1091
-                remote_path = os.path.join(remote_base, filename)
1092
-                worker = TransferWorker(self.sftp, file_path, remote_path, True)
1093
-            else:
1094
-                # Download to current local directory
1095
-                local_base = self.local_model.filePath(self.local_view.rootIndex())
1096
-                local_path = os.path.join(local_base, filename)
1097
-                worker = TransferWorker(self.sftp, file_path, local_path, False)
1098
-
1099
-            # Connect signals
1100
-            worker.signals.progress.connect(self._update_progress)
1101
-            worker.signals.finished.connect(self._transfer_complete)
1102
-            worker.signals.error.connect(self._transfer_error)
1103
-
1104
-            # Show progress bar
1105
-            self.progress_bar.setVisible(True)
1106
-            self.progress_bar.setValue(0)
1107
-
1108
-            # Start transfer
1109
-            self.threadpool.start(worker)
1110
-
1111
-    @pyqtSlot(int)
1112
-    def _update_progress(self, value):
1113
-        """Update progress bar"""
1114
-        self.progress_bar.setValue(value)
1115
-
1116
-    @pyqtSlot()
1117
-    def _transfer_complete(self):
1118
-        """Handle transfer completion"""
1119
-        self.progress_bar.setVisible(False)
1120
-        if hasattr(self, 'remote_model') and self.remote_model and self.sftp:
1121
-            # Only refresh the current directory shown in the path
1122
-            current_path = self.remote_path.text() or "/"
1123
-            parent_item = self.remote_model._find_item_by_path(current_path)
1124
-            if parent_item:
1125
-                self.remote_model._refresh_single_directory(parent_item)
1126
-        self.status_bar.showMessage("Transfer complete", 3000)
1127
-
1128
-    @pyqtSlot(str)
1129
-    def _transfer_error(self, error):
1130
-        """Handle transfer error"""
1131
-        self.progress_bar.setVisible(False)
1132
-        QMessageBox.critical(
1133
-            self, 
1134
-            "Transfer Error", 
1135
-            error,
1136
-            QMessageBox.StandardButton.Ok,
1137
-            QMessageBox.StandardButton.Ok
1138
-        )
1139
-
1140
-    def _handle_files_dropped(self, local_paths: List[str], target_directory: str):
1141
-        """Handle files and directories dropped onto remote view"""
1142
-        if not self.sftp:
1143
-            return
1144
-            
1145
-        # Update remote path to show where files are being uploaded
1146
-        self.remote_path.setText(target_directory)
1147
-        
1148
-        # Separate files and directories
1149
-        files = []
1150
-        directories = []
1151
-        
1152
-        for local_path in local_paths:
1153
-            if os.path.isdir(local_path):
1154
-                directories.append(local_path)
1155
-            else:
1156
-                files.append(local_path)
1157
-        
1158
-        # Handle directories
1159
-        if directories:
1160
-            reply = QMessageBox.question(
1161
-                self,
1162
-                "Upload Directories",
1163
-                f"Upload {len(directories)} director{'y' if len(directories) == 1 else 'ies'}?\n\n" +
1164
-                "This will recursively upload all contents.",
1165
-                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1166
-                QMessageBox.StandardButton.Yes
1167
-            )
1168
-            
1169
-            if reply == QMessageBox.StandardButton.Yes:
1170
-                for dir_path in directories:
1171
-                    dirname = os.path.basename(dir_path)
1172
-                    remote_path = os.path.join(target_directory, dirname).replace('\\', '/')
1173
-                    
1174
-                    worker = DirectoryTransferWorker(self.sftp, dir_path, remote_path, is_upload=True)
1175
-                    
1176
-                    # Connect signals
1177
-                    worker.signals.progress.connect(self._update_progress)
1178
-                    worker.signals.finished.connect(self._transfer_complete)
1179
-                    worker.signals.error.connect(self._transfer_error)
1180
-                    
1181
-                    # Show progress bar
1182
-                    self.progress_bar.setVisible(True)
1183
-                    self.progress_bar.setValue(0)
1184
-                    
1185
-                    # Start transfer
1186
-                    self.threadpool.start(worker)
1187
-        
1188
-        # Handle files
1189
-        for local_path in files:
1190
-            filename = os.path.basename(local_path)
1191
-            remote_path = os.path.join(target_directory, filename).replace('\\', '/')
1192
-            
1193
-            # Create transfer worker
1194
-            worker = TransferWorker(self.sftp, local_path, remote_path, True)
1195
-            
1196
-            # Connect signals
1197
-            worker.signals.progress.connect(self._update_progress)
1198
-            worker.signals.finished.connect(self._transfer_complete)
1199
-            worker.signals.error.connect(self._transfer_error)
1200
-            
1201
-            # Show progress bar
1202
-            self.progress_bar.setVisible(True)
1203
-            self.progress_bar.setValue(0)
1204
-            
1205
-            # Start transfer
1206
-            self.threadpool.start(worker)
1207
-            
1208
-        total_items = len(files) + len(directories)
1209
-        self.status_bar.showMessage(f"Uploading {total_items} item(s) to {target_directory}", 3000)
1210
-
1211
-    def _handle_remote_files_dropped(self, remote_items: List[tuple], local_directory: str):
1212
-        """Handle remote files and directories dropped onto local view"""
1213
-        if not self.sftp:
1214
-            return
1215
-        
1216
-        # Separate files and directories
1217
-        files = []
1218
-        directories = []
1219
-        
1220
-        for remote_path, is_dir in remote_items:
1221
-            if is_dir:
1222
-                dirname = os.path.basename(remote_path)
1223
-                directories.append((remote_path, dirname))
1224
-            else:
1225
-                files.append(remote_path)
1226
-        
1227
-        # Handle directories
1228
-        if directories:
1229
-            reply = QMessageBox.question(
1230
-                self,
1231
-                "Download Directories",
1232
-                f"Download {len(directories)} director{'y' if len(directories) == 1 else 'ies'}?\n\n" +
1233
-                "This will recursively download all contents.",
1234
-                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1235
-                QMessageBox.StandardButton.Yes
1236
-            )
1237
-            
1238
-            if reply == QMessageBox.StandardButton.Yes:
1239
-                for remote_path, dirname in directories:
1240
-                    local_path = os.path.join(local_directory, dirname)
1241
-                    
1242
-                    worker = DirectoryTransferWorker(self.sftp, remote_path, local_path, is_upload=False)
1243
-                    
1244
-                    # Connect signals
1245
-                    worker.signals.progress.connect(self._update_progress)
1246
-                    worker.signals.finished.connect(self._transfer_complete_download)
1247
-                    worker.signals.error.connect(self._transfer_error)
1248
-                    
1249
-                    # Show progress bar
1250
-                    self.progress_bar.setVisible(True)
1251
-                    self.progress_bar.setValue(0)
1252
-                    
1253
-                    # Start transfer
1254
-                    self.threadpool.start(worker)
1255
-        
1256
-        # Handle files
1257
-        for remote_path in files:
1258
-            filename = os.path.basename(remote_path)
1259
-            local_path = os.path.join(local_directory, filename)
1260
-            
1261
-            # Create transfer worker for download
1262
-            worker = TransferWorker(self.sftp, remote_path, local_path, False)
1263
-            
1264
-            # Connect signals
1265
-            worker.signals.progress.connect(self._update_progress)
1266
-            worker.signals.finished.connect(self._transfer_complete_download)
1267
-            worker.signals.error.connect(self._transfer_error)
1268
-            
1269
-            # Show progress bar
1270
-            self.progress_bar.setVisible(True)
1271
-            self.progress_bar.setValue(0)
1272
-            
1273
-            # Start transfer
1274
-            self.threadpool.start(worker)
1275
-            
1276
-        total_items = len(files) + len(directories)
1277
-        self.status_bar.showMessage(f"Downloading {total_items} item(s) to {local_directory}", 3000)
1278
-
1279
-    @pyqtSlot()
1280
-    def _transfer_complete_download(self):
1281
-        """Handle download completion - no need to refresh remote"""
1282
-        self.progress_bar.setVisible(False)
1283
-        self.status_bar.showMessage("Download complete", 3000)
1284
-
1285
-    def _create_directory(self, is_local: bool):
1286
-        """Create a new directory"""
1287
-        if is_local:
1288
-            current_path = self.local_model.filePath(self.local_view.rootIndex())
1289
-        else:
1290
-            current_path = self.remote_path.text() or "/"
1291
-        
1292
-        # Get directory name from user without stealing focus
1293
-        name, ok = QInputDialog.getText(
1294
-            self, 
1295
-            "Create Directory", 
1296
-            "Directory name:",
1297
-            flags=Qt.WindowType.WindowStaysOnTopHint
1298
-        )
1299
-        
1300
-        if ok and name:
1301
-            try:
1302
-                if is_local:
1303
-                    new_path = os.path.join(current_path, name)
1304
-                    os.makedirs(new_path, exist_ok=True)
1305
-                    self.status_bar.showMessage(f"Created directory: {name}", 3000)
1306
-                else:
1307
-                    if self.sftp:
1308
-                        new_path = os.path.join(current_path, name)
1309
-                        self.sftp.mkdir(new_path)
1310
-                        # Only refresh the parent directory
1311
-                        parent_item = self.remote_model._find_item_by_path(current_path)
1312
-                        if parent_item:
1313
-                            self.remote_model._refresh_single_directory(parent_item)
1314
-                        self.status_bar.showMessage(f"Created remote directory: {name}", 3000)
1315
-            except Exception as e:
1316
-                QMessageBox.warning(
1317
-                    self, 
1318
-                    "Error", 
1319
-                    f"Failed to create directory: {str(e)}",
1320
-                    QMessageBox.StandardButton.Ok,
1321
-                    QMessageBox.StandardButton.Ok
1322
-                )
1323
-    
1324
-    def _delete_selected(self, is_local: bool):
1325
-        """Delete selected files/directories"""
1326
-        view = self.local_view if is_local else self.remote_view
1327
-        selection = view.selectedIndexes()
1328
-        
1329
-        if not selection:
1330
-            return
1331
-        
1332
-        # Get unique items
1333
-        items = []
1334
-        for index in selection:
1335
-            if index.column() == 0:  # Only process name column
1336
-                if is_local:
1337
-                    path = self.local_model.filePath(index)
1338
-                    is_dir = self.local_model.isDir(index)
1339
-                    name = self.local_model.fileName(index)
1340
-                else:
1341
-                    file_info = self.remote_model.fileInfo(index)
1342
-                    if file_info:
1343
-                        path = file_info.full_path
1344
-                        is_dir = file_info.is_dir
1345
-                        name = file_info.name
1346
-                    else:
1347
-                        continue
1348
-                items.append((path, is_dir, name))
1349
-        
1350
-        if not items:
1351
-            return
1352
-        
1353
-        # Confirm deletion without stealing focus
1354
-        msg = f"Delete {len(items)} item(s)?\n\n"
1355
-        for path, is_dir, name in items[:5]:  # Show first 5 items
1356
-            msg += f"{'[DIR] ' if is_dir else ''}{name}\n"
1357
-        if len(items) > 5:
1358
-            msg += f"... and {len(items) - 5} more"
1359
-        
1360
-        reply = QMessageBox.question(
1361
-            self,
1362
-            "Confirm Delete",
1363
-            msg,
1364
-            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1365
-            QMessageBox.StandardButton.No
1366
-        )
1367
-        
1368
-        if reply == QMessageBox.StandardButton.Yes:
1369
-            errors = []
1370
-            for path, is_dir, name in items:
1371
-                try:
1372
-                    if is_local:
1373
-                        if is_dir:
1374
-                            shutil.rmtree(path)
1375
-                        else:
1376
-                            os.remove(path)
1377
-                    else:
1378
-                        if self.sftp:
1379
-                            if is_dir:
1380
-                                self._delete_remote_dir(path)
1381
-                            else:
1382
-                                self.sftp.remove(path)
1383
-                except Exception as e:
1384
-                    errors.append(f"{name}: {str(e)}")
1385
-            
1386
-            if errors:
1387
-                QMessageBox.warning(
1388
-                    self,
1389
-                    "Delete Errors",
1390
-                    "Some items could not be deleted:\n\n" + "\n".join(errors[:10]),
1391
-                    QMessageBox.StandardButton.Ok,
1392
-                    QMessageBox.StandardButton.Ok
1393
-                )
1394
-            else:
1395
-                self.status_bar.showMessage(f"Deleted {len(items)} item(s)", 3000)
1396
-            
1397
-            # Refresh views
1398
-            if is_local:
1399
-                # Local view auto-refreshes
1400
-                pass
1401
-            else:
1402
-                # For remote, only refresh the parent directories of deleted items
1403
-                if self.sftp:
1404
-                    # Get unique parent directories
1405
-                    parent_dirs = set()
1406
-                    for path, is_dir, name in items:
1407
-                        parent_dir = os.path.dirname(path)
1408
-                        parent_dirs.add(parent_dir)
1409
-                    
1410
-                    # Refresh each parent directory
1411
-                    for parent_dir in parent_dirs:
1412
-                        parent_item = self.remote_model._find_item_by_path(parent_dir)
1413
-                        if parent_item:
1414
-                            self.remote_model._refresh_single_directory(parent_item)
1415
-    
1416
-    def _delete_remote_dir(self, path: str):
1417
-        """Recursively delete remote directory"""
1418
-        if not self.sftp:
1419
-            return
1420
-            
1421
-        # List directory contents
1422
-        for item in self.sftp.listdir_attr(path):
1423
-            item_path = os.path.join(path, item.filename)
1424
-            if stat.S_ISDIR(item.st_mode):
1425
-                self._delete_remote_dir(item_path)
1426
-            else:
1427
-                self.sftp.remove(item_path)
1428
-        
1429
-        # Remove empty directory
1430
-        self.sftp.rmdir(path)
1431
-
1432
-    def delete_selected(self):
1433
-        """Delete selected items from whichever view has focus"""
1434
-        # Determine which view has focus or has a selection
1435
-        local_selection = self.local_view.selectedIndexes()
1436
-        remote_selection = self.remote_view.selectedIndexes() if self.sftp else []
1437
-        
1438
-        if local_selection and (not remote_selection or self.local_view.hasFocus()):
1439
-            self._delete_selected(True)
1440
-        elif remote_selection:
1441
-            self._delete_selected(False)
1442
-        else:
1443
-            QMessageBox.information(
1444
-                self,
1445
-                "Delete",
1446
-                "Please select items to delete",
1447
-                QMessageBox.StandardButton.Ok,
1448
-                QMessageBox.StandardButton.Ok
1449
-            )
1450
-
1451
-
1452
-def main():
1453
-    """Application entry point"""
1454
-    app = QApplication(sys.argv)
1455
-    app.setApplicationName("wulFTP")
1456
-
1457
-    # Set application style
1458
-    app.setStyle("Fusion")
1459
-
1460
-    window = WulFTPClient()
1461
-    window.show()
1462
-
1463
-    sys.exit(app.exec())
1464
-
1465
-
1466
-if __name__ == "__main__":
1467
-    main()