SFTP System
The SFTP system provides file management capabilities over SSH.
Architecture
Section titled “Architecture”┌─────────────────────────────────────────────┐│ SFTP UI Layer ││ - File browser (remote) ││ - File browser (local) ││ - Transfer queue │└─────────────────────────────────────────────┘ ↓┌─────────────────────────────────────────────┐│ SFTP State Management ││ - sftpProvider ││ - Path management ││ - Operation queue │└─────────────────────────────────────────────┘ ↓┌─────────────────────────────────────────────┐│ SFTP Protocol Layer ││ - SSH subsystem ││ - File operations ││ - Directory listing │└─────────────────────────────────────────────┘ ↓┌─────────────────────────────────────────────┐│ SSH Transport ││ - Secure channel ││ - Data streaming │└─────────────────────────────────────────────┘Connection Establishment
Section titled “Connection Establishment”SFTP Client Creation
Section titled “SFTP Client Creation”Future<SftpClient> createSftpClient(Spi spi) async { // 1. Get SSH client (reuse if available) final sshClient = await genClient(spi);
// 2. Open SFTP subsystem final sftp = await sshClient.openSftp();
return sftp;}Connection Reuse
Section titled “Connection Reuse”SFTP reuses existing SSH connections:
class ServerProvider { SSHClient? _sshClient; SftpClient? _sftpClient;
Future<SftpClient> getSftpClient(String spiId) async { _sftpClient ??= await _sshClient!.openSftp(); return _sftpClient!; }}File System Operations
Section titled “File System Operations”Directory Listing
Section titled “Directory Listing”Future<List<SftpFile>> listDirectory(String path) async { final sftp = await getSftpClient(spiId);
// List directory final files = await sftp.listDir(path);
// Sort based on settings files.sort((a, b) { switch (sortOption) { case SortOption.name: return a.name.toLowerCase().compareTo(b.name.toLowerCase()); case SortOption.size: return a.size.compareTo(b.size); case SortOption.time: return a.modified.compareTo(b.modified); } });
// Folders first if enabled if (showFoldersFirst) { final dirs = files.where((f) => f.isDirectory); final regular = files.where((f) => !f.isDirectory); return [...dirs, ...regular]; }
return files;}File Metadata
Section titled “File Metadata”class SftpFile { final String name; final String path; final int size; // Bytes final int modified; // Unix timestamp final String permissions; // e.g., "rwxr-xr-x" final String owner; final String group; final bool isDirectory; final bool isSymlink;
String get sizeFormatted => formatBytes(size); String get modifiedFormatted => formatDate(modified);}File Operations
Section titled “File Operations”Upload
Section titled “Upload”Future<void> uploadFile( String localPath, String remotePath,) async { final sftp = await getSftpClient(spiId);
// Create request final req = SftpReq( spi: spi, remotePath: remotePath, localPath: localPath, type: SftpReqType.upload, );
// Add to queue _transferQueue.add(req);
// Execute transfer with progress final file = File(localPath); final size = await file.length(); final stream = file.openRead();
await sftp.upload( stream: stream, toPath: remotePath, onProgress: (transferred) { _updateProgress(req, transferred, size); }, );
// Complete _transferQueue.remove(req);}Download
Section titled “Download”Future<void> downloadFile( String remotePath, String localPath,) async { final sftp = await getSftpClient(spiId);
// Create local file final file = File(localPath); final sink = file.openWrite();
// Download with progress final stat = await sftp.stat(remotePath);
await sftp.download( fromPath: remotePath, toSink: sink, onProgress: (transferred) { _updateProgress( SftpReq(...), transferred, stat.size, ); }, );
await sink.close();}Permission Editing
Section titled “Permission Editing”Future<void> setPermissions( String path, String permissions,) async { final sftp = await getSftpClient(spiId);
// Parse permissions (e.g., "rwxr-xr-x" or "755") final mode = parsePermissions(permissions);
// Set via SSH command (more reliable than SFTP) final ssh = await getSshClient(spiId); await ssh.exec('chmod $mode "$path"');}Path Management
Section titled “Path Management”Path Structure
Section titled “Path Structure”class PathWithPrefix { final String prefix; // e.g., "/home/user" final String path; // Relative or absolute
String get fullPath { if (path.startsWith('/')) { return path; // Absolute path } return '$prefix/$path'; // Relative path }
PathWithPrefix cd(String subPath) { return PathWithPrefix( prefix: fullPath, path: subPath, ); }}Navigation History
Section titled “Navigation History”class PathHistory { final List<String> _history = []; int _index = -1;
void push(String path) { // Remove forward history _history.removeRange(_index + 1, _history.length); _history.add(path); _index = _history.length - 1; }
String? back() { if (_index > 0) { _index--; return _history[_index]; } return null; }
String? forward() { if (_index < _history.length - 1) { _index++; return _history[_index]; } return null; }}Transfer System
Section titled “Transfer System”Transfer Request
Section titled “Transfer Request”class SftpReq { final Spi spi; final String remotePath; final String localPath; final SftpReqType type; final DateTime createdAt;
int? totalBytes; int? transferredBytes; String? error;}Progress Tracking
Section titled “Progress Tracking”class TransferProgress { final SftpReq request; final int total; final int transferred; final DateTime startTime;
double get percentage => (transferred / total) * 100; Duration get elapsed => DateTime.now().difference(startTime);
String get speedFormatted { final bytesPerSecond = transferred / elapsed.inSeconds; return formatSpeed(bytesPerSecond); }}Queue Management
Section titled “Queue Management”class TransferQueue { final List<SftpReq> _queue = []; final Map<String, TransferProgress> _progress = {}; int _concurrent = 3; // Max concurrent transfers
Future<void> process() async { final active = _progress.values.where((p) => p.isInProgress); if (active.length >= _concurrent) return;
final pending = _queue.where((r) => !_progress.containsKey(r.id)); for (final req in pending.take(_concurrent - active.length)) { _executeTransfer(req); } }
Future<void> _executeTransfer(SftpReq req) async { try { _progress[req.id] = TransferProgress.inProgress(req);
if (req.type == SftpReqType.upload) { await uploadFile(req.localPath, req.remotePath); } else { await downloadFile(req.remotePath, req.localPath); }
_progress[req.id] = TransferProgress.completed(req); } catch (e) { _progress[req.id] = TransferProgress.failed(req, e); } }}Local Storage Pattern
Section titled “Local Storage Pattern”Download Cache
Section titled “Download Cache”Downloaded files stored at:
String getLocalDownloadPath(String spiId, String remotePath) { final normalized = remotePath.replaceAll('/', '_'); return 'Paths.file/$spiId/$normalized';}Example:
- Remote:
/var/log/nginx/access.log - spiId:
server-123 - Local:
Paths.file/server-123/_var_log_nginx_access.log
File Editing
Section titled “File Editing”Edit Workflow
Section titled “Edit Workflow”Future<void> editFile(String path) async { final sftp = await getSftpClient(spiId);
// 1. Check size final stat = await sftp.stat(path); if (stat.size > editorMaxSize) { showWarning('File too large for built-in editor'); return; }
// 2. Download to temp final temp = await downloadToTemp(path);
// 3. Open in editor final content = await openEditor(temp.path);
// 4. Upload back await uploadFile(temp.path, path);
// 5. Cleanup await temp.delete();}External Editor Integration
Section titled “External Editor Integration”Future<void> editInExternalEditor(String path) async { final ssh = await getSshClient(spiId);
// Open terminal with editor final editor = getSetting('sftpEditor', 'vim'); await ssh.exec('$editor "$path"');
// User edits in terminal // After save, refresh SFTP view}Error Handling
Section titled “Error Handling”Permission Errors
Section titled “Permission Errors”try { await sftp.upload(...);} on SftpPermissionException { showError('Permission denied: ${stat.path}'); showHint('Check file permissions and ownership');}Connection Errors
Section titled “Connection Errors”try { await sftp.listDir(path);} on SftpConnectionException { showError('Connection lost'); await reconnect();}Space Errors
Section titled “Space Errors”try { await sftp.upload(...);} on SftpNoSpaceException { showError('Disk full on remote server');}Performance Optimizations
Section titled “Performance Optimizations”Directory Caching
Section titled “Directory Caching”class DirectoryCache { final Map<String, CachedDirectory> _cache = {}; final Duration ttl = Duration(minutes: 5);
Future<List<SftpFile>> list(String path) async { final cached = _cache[path]; if (cached != null && !cached.isExpired) { return cached.files; }
final files = await sftp.listDir(path); _cache[path] = CachedDirectory(files); return files; }}Lazy Loading
Section titled “Lazy Loading”For large directories (>1000 items):
List<SftpFile> loadPage(String path, int page, int pageSize) { final all = cache[path] ?? []; final start = page * pageSize; final end = start + pageSize; return all.sublist(start, end.clamp(0, all.length));}Pagination
Section titled “Pagination”class PaginatedDirectory { static const pageSize = 100;
Future<List<SftpFile>> getPage(int page) async { final offset = page * pageSize; return await sftp.listDir( path, offset: offset, limit: pageSize, ); }}