From d0dbda23cbb54bb74ff99cf3df2a6c16a1fc2947 Mon Sep 17 00:00:00 2001 From: Rorical <46294886+Rorical@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:15:08 +0100 Subject: [PATCH] feat(sftp): finish file browser release polish --- .../sftp/data/dartssh2_sftp_connection.dart | 98 +++- lib/features/sftp/domain/sftp_entry.dart | 147 ++++++ .../presentation/workspace_screen.dart | 1 + .../workspace_screen/file_path_helpers.dart | 14 +- .../workspace_screen/sessions_tabs.dart | 2 +- .../workspace_screen/sftp_components.dart | 4 +- .../workspace_screen/sftp_pane.dart | 430 +++++++++++++++++- lib/l10n/app_en.arb | 4 +- lib/l10n/app_ja.arb | 4 +- lib/l10n/app_zh.arb | 4 +- lib/l10n/generated/app_localizations.dart | 4 +- lib/l10n/generated/app_localizations_en.dart | 4 +- lib/l10n/generated/app_localizations_ja.dart | 5 +- lib/l10n/generated/app_localizations_zh.dart | 4 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 41 +- .../xcshareddata/swiftpm/Package.resolved | 14 - .../xcshareddata/swiftpm/Package.resolved | 14 - macos/Runner/DebugProfile.entitlements | 8 - pubspec.lock | 16 + pubspec.yaml | 1 + ...ssh2_sftp_connection_integration_test.dart | 272 +++++++++++ .../data/dartssh2_sftp_connection_test.dart | 17 + .../sftp/domain/sftp_permissions_test.dart | 31 ++ test/fixtures/sftp/Dockerfile | 19 + test/fixtures/sftp/README.md | 27 ++ test/fixtures/sftp/docker-compose.yml | 6 + test/workspace_smoke_test.dart | 35 +- test/workspace_smoke_test_fakes.dart | 2 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 33 files changed, 1143 insertions(+), 96 deletions(-) delete mode 100644 macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 test/features/sftp/data/dartssh2_sftp_connection_integration_test.dart create mode 100644 test/features/sftp/domain/sftp_permissions_test.dart create mode 100644 test/fixtures/sftp/Dockerfile create mode 100644 test/fixtures/sftp/README.md create mode 100644 test/fixtures/sftp/docker-compose.yml diff --git a/lib/features/sftp/data/dartssh2_sftp_connection.dart b/lib/features/sftp/data/dartssh2_sftp_connection.dart index 9576121..2d82e72 100644 --- a/lib/features/sftp/data/dartssh2_sftp_connection.dart +++ b/lib/features/sftp/data/dartssh2_sftp_connection.dart @@ -75,7 +75,7 @@ class DartSsh2SftpConnection implements SftpConnection { @override Future chmod(String path, SftpPermissions permissions) async { await _withMappedSftpErrors(() async { - final mode = int.parse(permissions.octal, radix: 8); + final mode = int.parse(permissions.normalizedOctal, radix: 8); await _sftp.setStat( path, ssh.SftpFileAttrs(mode: ssh.SftpFileMode.value(mode)), @@ -374,6 +374,17 @@ class DartSsh2SftpConnection implements SftpConnection { ); transferredBytes += await file.length(); } + for (final directory in directories.reversed) { + await transfer.waitIfPaused(); + final relativePath = p.relative(directory.path, from: root.path); + final remoteDirectory = relativePath == '.' + ? remotePath + : p.posix.join(remotePath, p.split(relativePath).join('/')); + await _trySetRemoteModifiedTime( + remoteDirectory, + (await directory.stat()).modified, + ); + } transfer.markCompleted(); _emitTransferProgress( controller, @@ -397,9 +408,12 @@ class DartSsh2SftpConnection implements SftpConnection { required StreamController controller, }) async { ssh.SftpFile? remoteFile; + var completed = false; + late final DateTime localModifiedAt; try { await transfer.waitIfPaused(); final localFile = File(localPath); + localModifiedAt = (await localFile.stat()).modified; remoteFile = await _sftp.open( remotePath, mode: @@ -425,9 +439,13 @@ class DartSsh2SftpConnection implements SftpConnection { transfer.bindWriter(writer); await writer.done; transfer.throwIfCanceled(); + completed = true; } finally { await remoteFile?.close(); } + if (completed) { + await _trySetRemoteModifiedTime(remotePath, localModifiedAt); + } } Future _downloadFileTransfer({ @@ -457,6 +475,7 @@ class DartSsh2SftpConnection implements SftpConnection { baseTransferredBytes: 0, fileBytes: totalBytes, aggregateTotalBytes: totalBytes, + remoteModifiedAt: _modifiedAtFromSftpSeconds(stat.modifyTime), transfer: transfer, controller: controller, ); @@ -530,6 +549,7 @@ class DartSsh2SftpConnection implements SftpConnection { baseTransferredBytes: transferredBytes, fileBytes: file.size, aggregateTotalBytes: aggregateTotalBytes, + remoteModifiedAt: file.modifiedAt, transfer: transfer, controller: controller, ); @@ -555,6 +575,7 @@ class DartSsh2SftpConnection implements SftpConnection { required int baseTransferredBytes, required int? fileBytes, required int? aggregateTotalBytes, + DateTime? remoteModifiedAt, required _SftpTransferControl transfer, required StreamController controller, }) async { @@ -566,6 +587,7 @@ class DartSsh2SftpConnection implements SftpConnection { StreamSubscription? subscription; final done = Completer(); var transferredBytes = 0; + var completed = false; Future fail(Object error, StackTrace stackTrace) async { if (!done.isCompleted) { @@ -630,19 +652,26 @@ class DartSsh2SftpConnection implements SftpConnection { ); await done.future; transfer.throwIfCanceled(); + completed = true; } finally { await subscription?.cancel(); await sink?.close(); await remoteFile?.close(); } + if (completed) { + await _trySetLocalModifiedTime(localFile, remoteModifiedAt); + } } Future<_RemoteTree> _collectRemoteTree(String rootPath) async { + final rootAttrs = await _sftp.stat(rootPath); final directories = [ SftpEntry( name: p.posix.basename(rootPath), path: rootPath, type: SftpEntryType.directory, + modifiedAt: _modifiedAtFromSftpSeconds(rootAttrs.modifyTime), + permissions: _permissionsFromSftpMode(rootAttrs.mode), ), ]; final files = []; @@ -684,6 +713,22 @@ class DartSsh2SftpConnection implements SftpConnection { } } + Future _trySetRemoteModifiedTime( + String path, + DateTime modifiedAt, + ) async { + try { + final seconds = _sftpSecondsFromDateTime(modifiedAt); + await _sftp.setStat( + path, + ssh.SftpFileAttrs(accessTime: seconds, modifyTime: seconds), + ); + } on Object { + // Timestamp preservation is best-effort because many SFTP servers reject + // SETSTAT even when the file transfer itself succeeded. + } + } + static SftpEntry mapName({required String path, required ssh.SftpName name}) { final attrs = name.attr; final entryPath = _joinRemotePath(path, name.filename); @@ -692,17 +737,8 @@ class DartSsh2SftpConnection implements SftpConnection { path: entryPath, type: _mapType(attrs.type), size: attrs.size, - modifiedAt: attrs.modifyTime == null - ? null - : DateTime.fromMillisecondsSinceEpoch( - attrs.modifyTime! * 1000, - isUtc: true, - ), - permissions: attrs.mode == null - ? null - : SftpPermissions( - (attrs.mode!.value & 0x1ff).toRadixString(8).padLeft(4, '0'), - ), + modifiedAt: _modifiedAtFromSftpSeconds(attrs.modifyTime), + permissions: _permissionsFromSftpMode(attrs.mode), owner: attrs.userID?.toString(), group: attrs.groupID?.toString(), isHidden: name.filename.startsWith('.'), @@ -710,6 +746,44 @@ class DartSsh2SftpConnection implements SftpConnection { } } +DateTime? _modifiedAtFromSftpSeconds(int? seconds) { + return seconds == null + ? null + : DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); +} + +SftpPermissions? _permissionsFromSftpMode(ssh.SftpFileMode? mode) { + return mode == null + ? null + : SftpPermissions.fromOctal( + (mode.value & 0xfff).toRadixString(8).padLeft(4, '0'), + ); +} + +int _sftpSecondsFromDateTime(DateTime value) { + return value.toUtc().millisecondsSinceEpoch ~/ 1000; +} + +Future _trySetLocalModifiedTime( + FileSystemEntity entity, + DateTime? modifiedAt, +) async { + if (modifiedAt == null) { + return; + } + try { + switch (entity) { + case File file: + await file.setLastModified(modifiedAt); + default: + return; + } + } on Object { + // Some platforms or target locations reject metadata writes; downloaded + // contents should remain successful when timestamp restoration fails. + } +} + class _RemoteTree { const _RemoteTree({required this.directories, required this.files}); diff --git a/lib/features/sftp/domain/sftp_entry.dart b/lib/features/sftp/domain/sftp_entry.dart index f63b956..5d9eb50 100644 --- a/lib/features/sftp/domain/sftp_entry.dart +++ b/lib/features/sftp/domain/sftp_entry.dart @@ -3,7 +3,33 @@ enum SftpEntryType { file, directory, symlink, unknown } class SftpPermissions { const SftpPermissions(this.octal); + factory SftpPermissions.fromOctal(String octal) { + final normalized = _normalizeOctalPermissions(octal); + if (normalized == null) { + throw FormatException('Invalid octal permissions: $octal'); + } + return SftpPermissions(normalized); + } + + static SftpPermissions? tryParse(String input) { + final trimmed = input.trim(); + final normalizedOctal = _normalizeOctalPermissions(trimmed); + if (normalizedOctal != null) { + return SftpPermissions(normalizedOctal); + } + final symbolicOctal = _octalFromSymbolicPermissions(trimmed); + if (symbolicOctal != null) { + return SftpPermissions(symbolicOctal); + } + return null; + } + final String octal; + + String get normalizedOctal => _normalizeOctalPermissions(octal) ?? octal; + + String get symbolic => + _symbolicPermissionsFromOctal(normalizedOctal) ?? octal; } class SftpEntry { @@ -29,3 +55,124 @@ class SftpEntry { final String? group; final bool isHidden; } + +String? _normalizeOctalPermissions(String value) { + if (!RegExp(r'^[0-7]{3,4}$').hasMatch(value)) { + return null; + } + return value.length == 3 ? '0$value' : value; +} + +String? _symbolicPermissionsFromOctal(String value) { + final normalized = _normalizeOctalPermissions(value); + if (normalized == null) { + return null; + } + final special = int.parse(normalized[0], radix: 8); + return [ + _symbolicPermissionTriplet( + int.parse(normalized[1], radix: 8), + special: special & 4 != 0, + executableSpecial: 's', + nonExecutableSpecial: 'S', + ), + _symbolicPermissionTriplet( + int.parse(normalized[2], radix: 8), + special: special & 2 != 0, + executableSpecial: 's', + nonExecutableSpecial: 'S', + ), + _symbolicPermissionTriplet( + int.parse(normalized[3], radix: 8), + special: special & 1 != 0, + executableSpecial: 't', + nonExecutableSpecial: 'T', + ), + ].join(); +} + +String _symbolicPermissionTriplet( + int digit, { + required bool special, + required String executableSpecial, + required String nonExecutableSpecial, +}) { + final read = digit & 4 != 0 ? 'r' : '-'; + final write = digit & 2 != 0 ? 'w' : '-'; + final executable = digit & 1 != 0; + final execute = special + ? (executable ? executableSpecial : nonExecutableSpecial) + : (executable ? 'x' : '-'); + return '$read$write$execute'; +} + +String? _octalFromSymbolicPermissions(String value) { + final symbolic = value.length == 10 && _looksLikeFileType(value[0]) + ? value.substring(1) + : value; + if (symbolic.length != 9) { + return null; + } + final user = _octalDigitFromSymbolicTriplet( + symbolic.substring(0, 3), + specialExecutable: 's', + specialNonExecutable: 'S', + ); + final group = _octalDigitFromSymbolicTriplet( + symbolic.substring(3, 6), + specialExecutable: 's', + specialNonExecutable: 'S', + ); + final other = _octalDigitFromSymbolicTriplet( + symbolic.substring(6, 9), + specialExecutable: 't', + specialNonExecutable: 'T', + ); + if (user == null || group == null || other == null) { + return null; + } + final special = + (user.special ? 4 : 0) + + (group.special ? 2 : 0) + + (other.special ? 1 : 0); + return '$special${user.digit}${group.digit}${other.digit}'; +} + +bool _looksLikeFileType(String value) { + return const {'-', 'd', 'l', 'c', 'b', 'p', 's', '?'}.contains(value); +} + +({int digit, bool special})? _octalDigitFromSymbolicTriplet( + String triplet, { + required String specialExecutable, + required String specialNonExecutable, +}) { + final read = switch (triplet[0]) { + 'r' => 4, + '-' => 0, + _ => null, + }; + final write = switch (triplet[1]) { + 'w' => 2, + '-' => 0, + _ => null, + }; + if (read == null || write == null) { + return null; + } + + final execute = switch (triplet[2]) { + 'x' => (digit: 1, special: false), + '-' => (digit: 0, special: false), + String value when value == specialExecutable => (digit: 1, special: true), + String value when value == specialNonExecutable => ( + digit: 0, + special: true, + ), + _ => null, + }; + if (execute == null) { + return null; + } + return (digit: read + write + execute.digit, special: execute.special); +} diff --git a/lib/features/workspace/presentation/workspace_screen.dart b/lib/features/workspace/presentation/workspace_screen.dart index d3a8e75..57f67b8 100644 --- a/lib/features/workspace/presentation/workspace_screen.dart +++ b/lib/features/workspace/presentation/workspace_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; +import 'package:desktop_drop/desktop_drop.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; diff --git a/lib/features/workspace/presentation/workspace_screen/file_path_helpers.dart b/lib/features/workspace/presentation/workspace_screen/file_path_helpers.dart index fce86da..dfef72e 100644 --- a/lib/features/workspace/presentation/workspace_screen/file_path_helpers.dart +++ b/lib/features/workspace/presentation/workspace_screen/file_path_helpers.dart @@ -11,6 +11,16 @@ String _fileName(String path) { return parts.last; } +String? _droppedItemName(DropItem item, String localPath) { + final itemName = item.name.trim(); + final candidate = itemName.isEmpty ? _fileName(localPath) : itemName; + final name = _fileName(candidate).trim(); + if (name.isEmpty || name == '.' || name == '..') { + return null; + } + return name; +} + String _parentPath(String path) { final normalized = _joinRemotePath(path); if (normalized == '/') { @@ -49,10 +59,6 @@ bool _sameRemotePath(String left, String right) { return _joinRemotePath(left) == _joinRemotePath(right); } -bool _isOctalPermissions(String value) { - return RegExp(r'^[0-7]{3,4}$').hasMatch(value); -} - String _joinRemotePath(String path) { final segments = []; for (final segment in path.split('/')) { diff --git a/lib/features/workspace/presentation/workspace_screen/sessions_tabs.dart b/lib/features/workspace/presentation/workspace_screen/sessions_tabs.dart index d576baf..6298cbe 100644 --- a/lib/features/workspace/presentation/workspace_screen/sessions_tabs.dart +++ b/lib/features/workspace/presentation/workspace_screen/sessions_tabs.dart @@ -355,7 +355,7 @@ class _ActiveTabView extends ConsumerWidget { :final rootPath, ) => _SftpPane( - key: ValueKey('${sessionId.value}:$rootPath:$currentPath'), + key: ValueKey('${sessionId.value}:$rootPath'), tabId: tab.id, hostId: tab.hostId, sourceMachineName: _sourceMachineNameFromTabTitle( diff --git a/lib/features/workspace/presentation/workspace_screen/sftp_components.dart b/lib/features/workspace/presentation/workspace_screen/sftp_components.dart index fedea8d..3a9603d 100644 --- a/lib/features/workspace/presentation/workspace_screen/sftp_components.dart +++ b/lib/features/workspace/presentation/workspace_screen/sftp_components.dart @@ -214,13 +214,13 @@ class _SftpEntryRow extends StatelessWidget { overflow: TextOverflow.ellipsis, ), trailing: SizedBox( - width: 360, + width: 408, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Flexible(child: Text(sizeLabel, overflow: TextOverflow.ellipsis)), const SizedBox(width: 16), - SizedBox(width: 44, child: Text(permissionsLabel)), + SizedBox(width: 88, child: Text(permissionsLabel)), SerlinkIconButton( visualDensity: VisualDensity.compact, tooltip: l10n.downloadAction, diff --git a/lib/features/workspace/presentation/workspace_screen/sftp_pane.dart b/lib/features/workspace/presentation/workspace_screen/sftp_pane.dart index 6034f10..d4f367f 100644 --- a/lib/features/workspace/presentation/workspace_screen/sftp_pane.dart +++ b/lib/features/workspace/presentation/workspace_screen/sftp_pane.dart @@ -27,21 +27,34 @@ class _SftpPane extends ConsumerStatefulWidget { } class _SftpPaneState extends ConsumerState<_SftpPane> { + static const _listCacheTtl = Duration(seconds: 5); + final TextEditingController _filterController = TextEditingController(); + final TextEditingController _pathController = TextEditingController(); + final FocusNode _pathFocusNode = FocusNode(); + final Map _listCache = {}; Future>? _entriesFuture; String _filterText = ''; String? _promptedDefaultDirectoryForPath; + bool _dropUploadActive = false; + bool _editingPath = false; + bool _pathSubmitting = false; bool _showHidden = false; bool _showingDefaultDirectoryPrompt = false; @override void initState() { super.initState(); + _pathController.text = widget.path; + _pathFocusNode.addListener(_handlePathFocusChanged); _reload(); } @override void dispose() { + _pathFocusNode.removeListener(_handlePathFocusChanged); + _pathFocusNode.dispose(); + _pathController.dispose(); _filterController.dispose(); super.dispose(); } @@ -49,9 +62,17 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { @override void didUpdateWidget(_SftpPane oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.sessionId != widget.sessionId || - oldWidget.path != widget.path || + if (oldWidget.sessionId != widget.sessionId) { + _invalidateListCache(); + _syncPathControllerToCurrentPath(); + _reload(bypassCache: true); + return; + } + if (oldWidget.path != widget.path || oldWidget.lifecycle != widget.lifecycle) { + if (oldWidget.path != widget.path) { + _syncPathControllerToCurrentPath(); + } _reload(); } } @@ -63,12 +84,11 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { final canList = widget.lifecycle == SessionLifecycleState.connected; final canOpenParent = canList && _showParentEntry; final canTransferDirectories = capabilities.localDirectoryTransfer; + final canDropUpload = canList && capabilities.isDesktop; + final pathContent = _buildPathContent(context, enabled: canList); final pathWidget = capabilities.prefersTouchUi - ? SizedBox( - width: _sftpToolbarPathWidth(context), - child: Text(widget.path, overflow: TextOverflow.ellipsis), - ) - : Expanded(child: Text(widget.path, overflow: TextOverflow.ellipsis)); + ? SizedBox(width: _sftpToolbarPathWidth(context), child: pathContent) + : Expanded(child: pathContent); return Column( children: [ @@ -177,7 +197,7 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { child: SerlinkIconButton( onPressed: canList ? () { - setState(_reload); + setState(() => _reload(bypassCache: true)); } : null, icon: const Icon(Icons.refresh, size: 18), @@ -189,16 +209,47 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { ), const Divider(height: 1), Expanded( - child: _buildBody( - context, - canList: canList, + child: _buildDropUploadTarget( + enabled: canDropUpload, canTransferDirectories: canTransferDirectories, + child: _SftpDropUploadSurface( + active: canDropUpload && _dropUploadActive, + child: _buildBody( + context, + canList: canList, + canTransferDirectories: canTransferDirectories, + ), + ), ), ), ], ); } + Widget _buildDropUploadTarget({ + required bool enabled, + required bool canTransferDirectories, + required Widget child, + }) { + if (!enabled) { + return child; + } + return DropTarget( + enable: enabled, + onDragEntered: (_) => _setDropUploadActive(true), + onDragExited: (_) => _setDropUploadActive(false), + onDragDone: (details) { + unawaited( + _enqueueDroppedUploads( + details.files, + canTransferDirectories: canTransferDirectories, + ), + ); + }, + child: child, + ); + } + Widget _buildBody( BuildContext context, { required bool canList, @@ -288,7 +339,7 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { ? Icons.folder_outlined : Icons.description_outlined, sizeLabel: isDirectory ? '' : _formatBytes(entry.size), - permissionsLabel: entry.permissions?.octal ?? '', + permissionsLabel: entry.permissions?.symbolic ?? '', metadataLabel: _sftpEntryMetadataLabel(entry), onTap: isDirectory ? () => _openDirectory(entry.path) @@ -312,11 +363,173 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { bool get _showParentEntry => !_sameRemotePath(widget.path, widget.rootPath); - void _reload() { + Widget _buildPathContent(BuildContext context, {required bool enabled}) { + final t = context.tokens; + final style = Theme.of(context).textTheme.bodyMedium?.copyWith( + color: enabled ? t.textPrimary : t.textMuted, + ); + if (_editingPath) { + return SerlinkTextField( + key: const ValueKey('sftp-path-field'), + controller: _pathController, + enabled: enabled, + focusNode: _pathFocusNode, + readOnly: _pathSubmitting, + autofocus: true, + autocorrect: false, + enableSuggestions: false, + selectAllOnFocus: true, + textInputAction: TextInputAction.go, + style: style, + decoration: const InputDecoration( + isCollapsed: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + filled: false, + ), + onSubmitted: (_) => _submitPath(), + ); + } + return MouseRegion( + cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer, + child: GestureDetector( + key: const ValueKey('sftp-path-display'), + behavior: HitTestBehavior.translucent, + onTap: enabled ? _startPathEditing : null, + child: Text(widget.path, overflow: TextOverflow.ellipsis, style: style), + ), + ); + } + + void _startPathEditing() { + if (_editingPath) { + return; + } + setState(() { + _editingPath = true; + _pathSubmitting = false; + _pathController.text = widget.path; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_editingPath) { + return; + } + _pathFocusNode.requestFocus(); + _selectPathText(); + }); + } + + void _handlePathFocusChanged() { + if (_pathFocusNode.hasFocus || !_editingPath || _pathSubmitting) { + return; + } + setState(() { + _editingPath = false; + _pathController.text = widget.path; + }); + } + + Future _submitPath() async { + if (_pathSubmitting) { + return; + } + final rawPath = _pathController.text.trim(); + if (rawPath.isEmpty || !rawPath.startsWith('/')) { + _showPathValidationMessage(context.l10n.sftpAbsolutePathError); + return; + } + final normalizedPath = _joinRemotePath(rawPath); + if (_sameRemotePath(normalizedPath, widget.path)) { + setState(() { + _editingPath = false; + _pathController.text = widget.path; + }); + _pathFocusNode.unfocus(); + return; + } + setState(() { + _pathSubmitting = true; + }); + try { + await _listDirectory(_connection(), normalizedPath, bypassCache: true); + if (!mounted) { + return; + } + setState(() { + _editingPath = false; + _pathSubmitting = false; + _pathController.text = normalizedPath; + }); + _pathFocusNode.unfocus(); + _openDirectory(normalizedPath); + } on Object catch (error) { + if (!mounted) { + return; + } + setState(() { + _pathSubmitting = false; + }); + _showPathValidationMessage(sftpFailureMessage(error)); + } + } + + void _showPathValidationMessage(String message) { + _pathFocusNode.requestFocus(); + _selectPathText(); + _showSnackBar(context, message); + } + + void _selectPathText() { + _pathController.selection = TextSelection( + baseOffset: 0, + extentOffset: _pathController.text.length, + ); + } + + void _syncPathControllerToCurrentPath() { + _editingPath = false; + _pathSubmitting = false; + _pathController.text = widget.path; + } + + void _reload({bool bypassCache = false}) { final connection = ref .read(workspaceRuntimeRegistryProvider) .sftpFor(widget.sessionId); - _entriesFuture = connection?.list(widget.path); + _entriesFuture = connection == null + ? null + : _listDirectory(connection, widget.path, bypassCache: bypassCache); + } + + Future> _listDirectory( + SftpConnection connection, + String path, { + bool bypassCache = false, + }) async { + final normalizedPath = _joinRemotePath(path); + if (!bypassCache) { + final cached = _listCache[normalizedPath]; + if (cached != null && + DateTime.now().difference(cached.cachedAt) < _listCacheTtl) { + return cached.entries; + } + } + final entries = await connection.list(normalizedPath); + _listCache[normalizedPath] = _SftpListCacheEntry( + entries: List.unmodifiable(entries), + cachedAt: DateTime.now(), + ); + return entries; + } + + void _invalidateListCache([String? path]) { + if (path == null) { + _listCache.clear(); + return; + } + _listCache.remove(_joinRemotePath(path)); } void _openParentDirectory() { @@ -376,7 +589,7 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { } final normalizedPath = _joinRemotePath(selectedPath); try { - await _connection().list(normalizedPath); + await _listDirectory(_connection(), normalizedPath, bypassCache: true); final hostId = widget.hostId; if (hostId != null) { await ref @@ -404,6 +617,15 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { .changeSftpDirectory(widget.tabId, path); } + void _setDropUploadActive(bool value) { + if (!mounted || _dropUploadActive == value) { + return; + } + setState(() { + _dropUploadActive = value; + }); + } + Future _createDirectory() async { final l10n = context.l10n; final name = await _showTextInputDialog( @@ -446,6 +668,7 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { localPath: file.path, remotePath: remotePath, ); + _invalidateListCache(widget.path); if (mounted) { _showSnackBar(context, l10n.sftpUploadQueuedSnack); } @@ -477,11 +700,104 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { localPath: directoryPath, remotePath: remotePath, ); + _invalidateListCache(widget.path); if (mounted) { _showSnackBar(context, l10n.sftpFolderUploadQueuedSnack); } } + Future _enqueueDroppedUploads( + List items, { + required bool canTransferDirectories, + }) async { + _setDropUploadActive(false); + var queued = 0; + var queuedDirectory = false; + for (final item in items) { + if (!mounted) { + return; + } + final kind = await _enqueueDroppedUpload( + item, + canTransferDirectories: canTransferDirectories, + ); + if (kind == null) { + continue; + } + queued += 1; + queuedDirectory = queuedDirectory || kind == TransferItemKind.directory; + } + if (queued == 0 || !mounted) { + return; + } + _invalidateListCache(widget.path); + _showSnackBar( + context, + queued == 1 && queuedDirectory + ? context.l10n.sftpFolderUploadQueuedSnack + : context.l10n.sftpUploadQueuedSnack, + ); + } + + Future _enqueueDroppedUpload( + DropItem item, { + required bool canTransferDirectories, + }) async { + final localPath = item.path.trim(); + if (localPath.isEmpty) { + return null; + } + final itemKind = await _droppedItemKind( + item, + localPath, + canTransferDirectories: canTransferDirectories, + ); + if (itemKind == null) { + return null; + } + final name = _droppedItemName(item, localPath); + if (name == null) { + return null; + } + final remotePath = await _resolveRemoteTransferConflict( + desiredRemotePath: _remoteChildPath(widget.path, name), + itemKind: itemKind, + ); + if (remotePath == null) { + return null; + } + ref + .read(transferQueueControllerProvider) + .enqueueUpload( + connection: _connection(), + itemKind: itemKind, + sourceHostId: widget.hostId, + sourceMachineName: widget.sourceMachineName, + localPath: localPath, + remotePath: remotePath, + ); + return itemKind; + } + + Future _droppedItemKind( + DropItem item, + String localPath, { + required bool canTransferDirectories, + }) async { + if (item is DropItemDirectory) { + return canTransferDirectories ? TransferItemKind.directory : null; + } + final type = await FileSystemEntity.type(localPath); + return switch (type) { + FileSystemEntityType.directory => + canTransferDirectories ? TransferItemKind.directory : null, + FileSystemEntityType.file || + FileSystemEntityType.link => TransferItemKind.file, + FileSystemEntityType.notFound => null, + _ => null, + }; + } + Future _enqueueDownload(SftpEntry entry) async { final l10n = context.l10n; final itemKind = entry.type == SftpEntryType.directory @@ -609,7 +925,11 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { Future _nextAvailableRemotePath(String desiredRemotePath) async { final parent = _parentPath(desiredRemotePath); - final entries = await _connection().list(parent); + final entries = await _listDirectory( + _connection(), + parent, + bypassCache: true, + ); return nextRemoteConflictPath(desiredRemotePath, { for (final entry in entries) entry.path, }); @@ -678,21 +998,22 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { Future _chmodEntry(SftpEntry entry) async { final l10n = context.l10n; - final octal = await _showTextInputDialog( + final input = await _showTextInputDialog( context, title: l10n.sftpChangePermissionsTitle, label: l10n.sftpOctalPermissionsLabel, - initialValue: entry.permissions?.octal ?? '', + initialValue: entry.permissions?.symbolic ?? '', confirmLabel: l10n.applyAction, ); - if (octal == null || !_isOctalPermissions(octal.trim())) { - if (mounted && octal != null) { + final permissions = input == null ? null : SftpPermissions.tryParse(input); + if (permissions == null) { + if (mounted && input != null) { _showSnackBar(context, l10n.sftpPermissionsOctalError); } return; } await _runSftpOperation( - () => _connection().chmod(entry.path, SftpPermissions(octal.trim())), + () => _connection().chmod(entry.path, permissions), successMessage: l10n.sftpPermissionsUpdatedSnack, ); } @@ -753,7 +1074,10 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { if (!mounted) { return; } - setState(_reload); + setState(() { + _invalidateListCache(); + _reload(bypassCache: true); + }); _showSnackBar(context, successMessage); } on Object catch (error) { if (mounted) { @@ -774,11 +1098,71 @@ class _SftpPaneState extends ConsumerState<_SftpPane> { Future _remoteEntryExists(String remotePath) async { final parent = _parentPath(remotePath); - final entries = await _connection().list(parent); + final entries = await _listDirectory( + _connection(), + parent, + bypassCache: true, + ); return entries.any((entry) => entry.path == remotePath); } } +class _SftpListCacheEntry { + const _SftpListCacheEntry({required this.entries, required this.cachedAt}); + + final List entries; + final DateTime cachedAt; +} + +class _SftpDropUploadSurface extends StatelessWidget { + const _SftpDropUploadSurface({required this.active, required this.child}); + + final bool active; + final Widget child; + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return Stack( + children: [ + Positioned.fill(child: child), + if (active) + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: t.accentPrimary.withValues(alpha: 0.08), + border: Border.all( + color: t.accentPrimary.withValues(alpha: 0.7), + width: 2, + ), + ), + child: Center( + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: t.surfaceGlass, + border: Border.all( + color: t.accentPrimary.withValues(alpha: 0.45), + ), + shape: BoxShape.circle, + ), + child: Icon( + Icons.upload_file_outlined, + size: 30, + color: t.accentPrimary, + ), + ), + ), + ), + ), + ), + ], + ); + } +} + double _sftpToolbarPathWidth(BuildContext context) { final width = MediaQuery.sizeOf(context).width; return math.max(140, math.min(360, width * 0.32)); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3db5464..bb807e9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -758,8 +758,8 @@ "sftpEntryRenamedSnack": "Entry renamed.", "sftpEntryMovedSnack": "Entry moved.", "sftpChangePermissionsTitle": "Change permissions", - "sftpOctalPermissionsLabel": "Octal permissions", - "sftpPermissionsOctalError": "Permissions must be a 3 or 4 digit octal.", + "sftpOctalPermissionsLabel": "Permissions (octal or symbolic)", + "sftpPermissionsOctalError": "Permissions must be octal, like 0644, or symbolic, like rw-r--r--.", "sftpPermissionsUpdatedSnack": "Permissions updated.", "sftpDeleteEntryTitle": "Delete {name}?", "@sftpDeleteEntryTitle": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 8f56d38..b115a78 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -501,8 +501,8 @@ "sftpEntryRenamedSnack": "項目名を変更しました。", "sftpEntryMovedSnack": "項目を移動しました。", "sftpChangePermissionsTitle": "権限を変更", - "sftpOctalPermissionsLabel": "8 進権限", - "sftpPermissionsOctalError": "権限は 3 桁または 4 桁の 8 進数で入力してください。", + "sftpOctalPermissionsLabel": "権限(8 進数または記号)", + "sftpPermissionsOctalError": "権限は 0644 のような 8 進数、または rw-r--r-- のような記号形式で入力してください。", "sftpPermissionsUpdatedSnack": "権限を更新しました。", "sftpDeleteEntryTitle": "{name} を削除しますか?", "sftpDeleteDirectoryBody": "リモートディレクトリとその内容を削除します。", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 61aa5e2..479f431 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -501,8 +501,8 @@ "sftpEntryRenamedSnack": "条目已重命名。", "sftpEntryMovedSnack": "条目已移动。", "sftpChangePermissionsTitle": "更改权限", - "sftpOctalPermissionsLabel": "八进制权限", - "sftpPermissionsOctalError": "权限必须是 3 或 4 位八进制数。", + "sftpOctalPermissionsLabel": "权限(八进制或符号)", + "sftpPermissionsOctalError": "权限必须是八进制(如 0644)或符号格式(如 rw-r--r--)。", "sftpPermissionsUpdatedSnack": "权限已更新。", "sftpDeleteEntryTitle": "删除 {name}?", "sftpDeleteDirectoryBody": "这会删除远程目录及其内容。", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 858db27..5a1fcd0 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -3109,13 +3109,13 @@ abstract class AppLocalizations { /// No description provided for @sftpOctalPermissionsLabel. /// /// In en, this message translates to: - /// **'Octal permissions'** + /// **'Permissions (octal or symbolic)'** String get sftpOctalPermissionsLabel; /// No description provided for @sftpPermissionsOctalError. /// /// In en, this message translates to: - /// **'Permissions must be a 3 or 4 digit octal.'** + /// **'Permissions must be octal, like 0644, or symbolic, like rw-r--r--.'** String get sftpPermissionsOctalError; /// No description provided for @sftpPermissionsUpdatedSnack. diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 62bdb9f..140f9ef 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1697,11 +1697,11 @@ class AppLocalizationsEn extends AppLocalizations { String get sftpChangePermissionsTitle => 'Change permissions'; @override - String get sftpOctalPermissionsLabel => 'Octal permissions'; + String get sftpOctalPermissionsLabel => 'Permissions (octal or symbolic)'; @override String get sftpPermissionsOctalError => - 'Permissions must be a 3 or 4 digit octal.'; + 'Permissions must be octal, like 0644, or symbolic, like rw-r--r--.'; @override String get sftpPermissionsUpdatedSnack => 'Permissions updated.'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index eef571d..427149b 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1612,10 +1612,11 @@ class AppLocalizationsJa extends AppLocalizations { String get sftpChangePermissionsTitle => '権限を変更'; @override - String get sftpOctalPermissionsLabel => '8 進権限'; + String get sftpOctalPermissionsLabel => '権限(8 進数または記号)'; @override - String get sftpPermissionsOctalError => '権限は 3 桁または 4 桁の 8 進数で入力してください。'; + String get sftpPermissionsOctalError => + '権限は 0644 のような 8 進数、または rw-r--r-- のような記号形式で入力してください。'; @override String get sftpPermissionsUpdatedSnack => '権限を更新しました。'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 446e510..a4fa290 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1599,10 +1599,10 @@ class AppLocalizationsZh extends AppLocalizations { String get sftpChangePermissionsTitle => '更改权限'; @override - String get sftpOctalPermissionsLabel => '八进制权限'; + String get sftpOctalPermissionsLabel => '权限(八进制或符号)'; @override - String get sftpPermissionsOctalError => '权限必须是 3 或 4 位八进制数。'; + String get sftpPermissionsOctalError => '权限必须是八进制(如 0644)或符号格式(如 rw-r--r--)。'; @override String get sftpPermissionsUpdatedSnack => '权限已更新。'; diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 2368a49..3a290a7 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,11 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) desktop_drop_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin"); + desktop_drop_plugin_register_with_registrar(desktop_drop_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index fbd294c..4a2506c 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + desktop_drop file_selector_linux flutter_secure_storage_linux sentry_flutter diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9391283..cf7b847 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,14 @@ import FlutterMacOS import Foundation +import desktop_drop import file_selector_macos import flutter_secure_storage_darwin import package_info_plus import sentry_flutter func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index b4ab695..608c03f 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,21 +1,60 @@ PODS: + - desktop_drop (0.0.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS - flutter_pty (0.0.1): - FlutterMacOS + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS - FlutterMacOS (1.0.0) + - package_info_plus (0.0.1): + - FlutterMacOS + - Sentry/HybridSDK (8.58.3) + - sentry_flutter (9.21.0): + - Flutter + - FlutterMacOS + - Sentry/HybridSDK (= 8.58.3) DEPENDENCIES: + - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_pty (from `Flutter/ephemeral/.symlinks/plugins/flutter_pty/macos`) + - flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - FlutterMacOS (from `Flutter/ephemeral`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`) + +SPEC REPOS: + trunk: + - Sentry EXTERNAL SOURCES: + desktop_drop: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos flutter_pty: :path: Flutter/ephemeral/.symlinks/plugins/flutter_pty/macos + flutter_secure_storage_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin FlutterMacOS: :path: Flutter/ephemeral + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + sentry_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos SPEC CHECKSUMS: - flutter_pty: 1e360feaf8b1a213d2d50da3c076005b8eef1eee + desktop_drop: 1eeeb9484299585770b6461e4f3d60950e99efa2 + file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150 + flutter_pty: 41b6f848ade294be726a6b94cdd4a67c3bc52f59 + flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b + Sentry: 108fdbb76299c4189af12246bf0308c09c278922 + sentry_flutter: 980e0c8a15a07075f508f941653786ced138609b PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 597aa42..0000000 --- a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "sentry-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/getsentry/sentry-cocoa", - "state" : { - "revision" : "dad229c665bfd043c5d80ac7aa77717cbd19a1c3", - "version" : "8.58.3" - } - } - ], - "version" : 2 -} diff --git a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 597aa42..0000000 --- a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "sentry-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/getsentry/sentry-cocoa", - "state" : { - "revision" : "dad229c665bfd043c5d80ac7aa77717cbd19a1c3", - "version" : "8.58.3" - } - } - ], - "version" : 2 -} diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 5d276bd..4eac53f 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,13 +6,5 @@ com.apple.security.network.server - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.icloud-container-identifiers - - iCloud.com.alkinum.serlink - diff --git a/pubspec.lock b/pubspec.lock index e35cab8..d5f5782 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -225,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.17.1" + desktop_drop: + dependency: "direct main" + description: + name: desktop_drop + sha256: aa1e797255bfbc76f9eb5aa4f61e5b68dbf69962ab1be6495816d2f251bc0d1f + url: "https://pub.dev" + source: hosted + version: "0.7.1" dio: dependency: "direct main" description: @@ -1066,6 +1074,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" uuid: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 3db7800..11bb777 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,7 @@ dependencies: flutter_pty: ^0.4.2 ffi: ^2.2.0 forui: ^0.22.3 + desktop_drop: ^0.7.1 dependency_overrides: xterm: diff --git a/test/features/sftp/data/dartssh2_sftp_connection_integration_test.dart b/test/features/sftp/data/dartssh2_sftp_connection_integration_test.dart new file mode 100644 index 0000000..7a10e54 --- /dev/null +++ b/test/features/sftp/data/dartssh2_sftp_connection_integration_test.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dartssh2/dartssh2.dart' as ssh; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:serlink/core/ids/entity_id.dart'; +import 'package:serlink/features/sftp/application/sftp_connection.dart'; +import 'package:serlink/features/sftp/application/sftp_failure.dart'; +import 'package:serlink/features/sftp/data/dartssh2_sftp_connection.dart'; +import 'package:serlink/features/sftp/domain/sftp_entry.dart'; + +void main() { + final settings = _SftpIntegrationSettings.fromEnvironment(); + + test( + 'round trips chmod, timestamps, recursive transfers, overwrites, and failures', + () async { + final connection = await _openConnection(settings); + final runRoot = p.posix.join( + settings.remoteRoot, + 'serlink-${DateTime.now().microsecondsSinceEpoch}', + ); + await connection.mkdir(runRoot); + try { + await _exerciseFileUpload(connection, runRoot); + await _exerciseDirectoryTransfer(connection, runRoot); + await _exerciseOverwrite(connection, runRoot); + await _exerciseFailureMapping(connection, runRoot); + } finally { + await connection.deleteDirectory(runRoot, recursive: true); + await connection.close(); + } + }, + skip: settings.enabled + ? false + : 'Set SERLINK_SFTP_INTEGRATION=1 and start test/fixtures/sftp.', + timeout: const Timeout(Duration(minutes: 2)), + ); +} + +Future _openConnection( + _SftpIntegrationSettings settings, +) async { + final socket = await ssh.SSHSocket.connect( + settings.host, + settings.port, + timeout: const Duration(seconds: 10), + ); + final client = ssh.SSHClient( + socket, + username: settings.username, + onPasswordRequest: () => settings.password, + ); + final sftp = await client.sftp(); + return DartSsh2SftpConnection(sftpClient: sftp, sshClient: client); +} + +Future _exerciseFileUpload( + DartSsh2SftpConnection connection, + String runRoot, +) async { + final localDirectory = await Directory.systemTemp.createTemp( + 'serlink-sftp-file-', + ); + addTearDown(() async { + if (await localDirectory.exists()) { + await localDirectory.delete(recursive: true); + } + }); + final localFile = File(p.join(localDirectory.path, 'app.env')); + await localFile.writeAsString('PORT=8080\n'); + final modifiedAt = DateTime.utc(2026, 1, 2, 3, 4, 5); + await localFile.setLastModified(modifiedAt); + + final remotePath = p.posix.join(runRoot, 'app.env'); + await _expectCompleted( + connection.upload( + taskId: TransferTaskId('integration-upload-file'), + itemKind: TransferItemKind.file, + localPath: localFile.path, + remotePath: remotePath, + ), + ); + await connection.chmod(remotePath, SftpPermissions.tryParse('rwx------')!); + + final entries = await connection.list(runRoot); + final uploaded = entries.singleWhere((entry) => entry.path == remotePath); + expect(uploaded.permissions!.symbolic, 'rwx------'); + expect(uploaded.modifiedAt, _withinSeconds(modifiedAt, 3)); + + final downloadPath = p.join(localDirectory.path, 'downloaded.env'); + await _expectCompleted( + connection.download( + taskId: TransferTaskId('integration-download-file'), + itemKind: TransferItemKind.file, + remotePath: remotePath, + localPath: downloadPath, + ), + ); + expect(await File(downloadPath).readAsString(), 'PORT=8080\n'); + expect( + (await File(downloadPath).stat()).modified, + _withinSeconds(modifiedAt, 3), + ); +} + +Future _exerciseDirectoryTransfer( + DartSsh2SftpConnection connection, + String runRoot, +) async { + final localRoot = await Directory.systemTemp.createTemp('serlink-sftp-tree-'); + final downloadRoot = await Directory.systemTemp.createTemp( + 'serlink-sftp-tree-download-', + ); + addTearDown(() async { + for (final directory in [localRoot, downloadRoot]) { + if (await directory.exists()) { + await directory.delete(recursive: true); + } + } + }); + + final nested = Directory(p.join(localRoot.path, 'config', 'prod')); + await nested.create(recursive: true); + final nestedFile = File(p.join(nested.path, 'service.txt')); + await nestedFile.writeAsString('worker=true\n'); + final modifiedAt = DateTime.utc(2026, 2, 3, 4, 5, 6); + await nestedFile.setLastModified(modifiedAt); + + final remoteTree = p.posix.join(runRoot, 'tree'); + await _expectCompleted( + connection.upload( + taskId: TransferTaskId('integration-upload-directory'), + itemKind: TransferItemKind.directory, + localPath: localRoot.path, + remotePath: remoteTree, + ), + ); + + final remoteNested = await connection.list( + p.posix.join(remoteTree, 'config', 'prod'), + ); + final remoteFile = remoteNested.singleWhere( + (entry) => entry.name == 'service.txt', + ); + expect(remoteFile.modifiedAt, _withinSeconds(modifiedAt, 3)); + + await _expectCompleted( + connection.download( + taskId: TransferTaskId('integration-download-directory'), + itemKind: TransferItemKind.directory, + remotePath: remoteTree, + localPath: downloadRoot.path, + ), + ); + final downloadedFile = File( + p.join(downloadRoot.path, 'config', 'prod', 'service.txt'), + ); + expect(await downloadedFile.readAsString(), 'worker=true\n'); + expect((await downloadedFile.stat()).modified, _withinSeconds(modifiedAt, 3)); +} + +Future _exerciseOverwrite( + DartSsh2SftpConnection connection, + String runRoot, +) async { + final localDirectory = await Directory.systemTemp.createTemp( + 'serlink-sftp-overwrite-', + ); + addTearDown(() async { + if (await localDirectory.exists()) { + await localDirectory.delete(recursive: true); + } + }); + final localFile = File(p.join(localDirectory.path, 'overwrite.txt')); + final remotePath = p.posix.join(runRoot, 'overwrite.txt'); + + await localFile.writeAsString('first'); + await _expectCompleted( + connection.upload( + taskId: TransferTaskId('integration-upload-overwrite-first'), + itemKind: TransferItemKind.file, + localPath: localFile.path, + remotePath: remotePath, + ), + ); + await localFile.writeAsString('second'); + await _expectCompleted( + connection.upload( + taskId: TransferTaskId('integration-upload-overwrite-second'), + itemKind: TransferItemKind.file, + localPath: localFile.path, + remotePath: remotePath, + ), + ); + + final downloadPath = p.join(localDirectory.path, 'overwrite-download.txt'); + await _expectCompleted( + connection.download( + taskId: TransferTaskId('integration-download-overwrite'), + itemKind: TransferItemKind.file, + remotePath: remotePath, + localPath: downloadPath, + ), + ); + expect(await File(downloadPath).readAsString(), 'second'); +} + +Future _exerciseFailureMapping( + DartSsh2SftpConnection connection, + String runRoot, +) async { + await expectLater( + connection.list(p.posix.join(runRoot, 'missing')), + throwsA( + isA().having( + (error) => error.failure.code, + 'code', + SftpFailureCode.notFound, + ), + ), + ); +} + +Future _expectCompleted(Stream progress) async { + TransferProgress? last; + await for (final update in progress) { + last = update; + } + expect(last?.state, TransferState.completed); +} + +Matcher _withinSeconds(DateTime expected, int seconds) { + return predicate((actual) { + if (actual == null) { + return false; + } + return actual.toUtc().difference(expected).abs() <= + Duration(seconds: seconds); + }, 'within $seconds seconds of $expected'); +} + +class _SftpIntegrationSettings { + const _SftpIntegrationSettings({ + required this.enabled, + required this.host, + required this.port, + required this.username, + required this.password, + required this.remoteRoot, + }); + + factory _SftpIntegrationSettings.fromEnvironment() { + final environment = Platform.environment; + return _SftpIntegrationSettings( + enabled: environment['SERLINK_SFTP_INTEGRATION'] == '1', + host: environment['SERLINK_SFTP_HOST'] ?? '127.0.0.1', + port: int.tryParse(environment['SERLINK_SFTP_PORT'] ?? '') ?? 2222, + username: environment['SERLINK_SFTP_USER'] ?? 'serlink', + password: environment['SERLINK_SFTP_PASSWORD'] ?? 'serlink', + remoteRoot: environment['SERLINK_SFTP_ROOT'] ?? '/home/serlink/workspace', + ); + } + + final bool enabled; + final String host; + final int port; + final String username; + final String password; + final String remoteRoot; +} diff --git a/test/features/sftp/data/dartssh2_sftp_connection_test.dart b/test/features/sftp/data/dartssh2_sftp_connection_test.dart index 08c8c50..0b42ca5 100644 --- a/test/features/sftp/data/dartssh2_sftp_connection_test.dart +++ b/test/features/sftp/data/dartssh2_sftp_connection_test.dart @@ -24,6 +24,7 @@ void main() { expect(entry.path, '/var/www/.releases'); expect(entry.type, SftpEntryType.directory); expect(entry.permissions!.octal, '0755'); + expect(entry.permissions!.symbolic, 'rwxr-xr-x'); expect(entry.owner, '501'); expect(entry.group, '20'); expect(entry.isHidden, isTrue); @@ -50,4 +51,20 @@ void main() { expect(entry.type, SftpEntryType.file); expect(entry.permissions!.octal, '0640'); }); + + test('preserves special permission bits from remote mode', () { + final entry = DartSsh2SftpConnection.mapName( + path: '/bin', + name: ssh.SftpName( + filename: 'deploy', + longname: '', + attr: ssh.SftpFileAttrs( + mode: ssh.SftpFileMode.value(int.parse('104755', radix: 8)), + ), + ), + ); + + expect(entry.permissions!.octal, '4755'); + expect(entry.permissions!.symbolic, 'rwsr-xr-x'); + }); } diff --git a/test/features/sftp/domain/sftp_permissions_test.dart b/test/features/sftp/domain/sftp_permissions_test.dart new file mode 100644 index 0000000..dbd2357 --- /dev/null +++ b/test/features/sftp/domain/sftp_permissions_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:serlink/features/sftp/domain/sftp_entry.dart'; + +void main() { + test('normalizes octal permissions', () { + final permissions = SftpPermissions.tryParse('755'); + + expect(permissions, isNotNull); + expect(permissions!.octal, '0755'); + expect(permissions.normalizedOctal, '0755'); + expect(permissions.symbolic, 'rwxr-xr-x'); + }); + + test('renders special permission bits symbolically', () { + expect(SftpPermissions.fromOctal('4755').symbolic, 'rwsr-xr-x'); + expect(SftpPermissions.fromOctal('2750').symbolic, 'rwxr-s---'); + expect(SftpPermissions.fromOctal('1644').symbolic, 'rw-r--r-T'); + }); + + test('parses symbolic permissions to normalized octal', () { + expect(SftpPermissions.tryParse('rw-r--r--')!.octal, '0644'); + expect(SftpPermissions.tryParse('-rwxr-xr-x')!.octal, '0755'); + expect(SftpPermissions.tryParse('rwsr-xr-x')!.octal, '4755'); + }); + + test('rejects invalid permission input', () { + expect(SftpPermissions.tryParse('888'), isNull); + expect(SftpPermissions.tryParse('rwxr-x'), isNull); + expect(SftpPermissions.tryParse('rwxrwxrwq'), isNull); + }); +} diff --git a/test/fixtures/sftp/Dockerfile b/test/fixtures/sftp/Dockerfile new file mode 100644 index 0000000..0447848 --- /dev/null +++ b/test/fixtures/sftp/Dockerfile @@ -0,0 +1,19 @@ +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssh-server \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -s /bin/bash serlink \ + && echo 'serlink:serlink' | chpasswd \ + && mkdir -p /run/sshd /home/serlink/workspace \ + && chown -R serlink:serlink /home/serlink + +RUN printf '%s\n' \ + 'PasswordAuthentication yes' \ + 'PermitRootLogin no' \ + > /etc/ssh/sshd_config.d/serlink-sftp.conf + +EXPOSE 22 + +CMD ["/usr/sbin/sshd", "-D", "-e"] diff --git a/test/fixtures/sftp/README.md b/test/fixtures/sftp/README.md new file mode 100644 index 0000000..afa5662 --- /dev/null +++ b/test/fixtures/sftp/README.md @@ -0,0 +1,27 @@ +# SFTP integration fixture + +This fixture starts a local OpenSSH server with password auth and SFTP enabled. +It is only for opt-in integration tests; normal `flutter test` skips these tests. + +Run the fixture: + +```sh +docker compose -f test/fixtures/sftp/docker-compose.yml up --build +``` + +In another terminal, run: + +```sh +SERLINK_SFTP_INTEGRATION=1 flutter test test/features/sftp/data/dartssh2_sftp_connection_integration_test.dart +``` + +Defaults: + +- Host: `127.0.0.1` +- Port: `2222` +- User: `serlink` +- Password: `serlink` +- Remote root: `/home/serlink/workspace` + +Override any value with `SERLINK_SFTP_HOST`, `SERLINK_SFTP_PORT`, +`SERLINK_SFTP_USER`, `SERLINK_SFTP_PASSWORD`, or `SERLINK_SFTP_ROOT`. diff --git a/test/fixtures/sftp/docker-compose.yml b/test/fixtures/sftp/docker-compose.yml new file mode 100644 index 0000000..da57eec --- /dev/null +++ b/test/fixtures/sftp/docker-compose.yml @@ -0,0 +1,6 @@ +services: + sftp: + build: + context: . + ports: + - "2222:22" diff --git a/test/workspace_smoke_test.dart b/test/workspace_smoke_test.dart index 639efe0..df1a615 100644 --- a/test/workspace_smoke_test.dart +++ b/test/workspace_smoke_test.dart @@ -369,16 +369,45 @@ void main() { await tester.tap(find.widgetWithText(SerlinkFilledButton, 'Create')); await tester.pumpAndSettle(); expect(find.text('releases'), findsOneWidget); + final rootListCountAfterCreate = sshService.sftp.listCounts['/'] ?? 0; await tester.tap(find.text('releases')); await tester.pumpAndSettle(); expect(find.text('Empty Folder'), findsOneWidget); expect(find.text('/releases'), findsOneWidget); + expect(sshService.sftp.listCounts['/releases'], 1); await tester.tap(find.byKey(const ValueKey('sftp-parent-button'))); await tester.pumpAndSettle(); expect(find.text('/'), findsOneWidget); expect(find.text('releases'), findsOneWidget); + expect(sshService.sftp.listCounts['/'], rootListCountAfterCreate); + + await tester.tap(find.byKey(const ValueKey('sftp-path-display'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('sftp-path-field')), + '/releases', + ); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pumpAndSettle(); + expect(find.text('/releases'), findsOneWidget); + expect(find.text('Empty Folder'), findsOneWidget); + expect(sshService.sftp.listCounts['/releases'], 2); + + await tester.tap(find.byKey(const ValueKey('sftp-path-display'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('sftp-path-field')), '/'); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pumpAndSettle(); + expect(find.text('/'), findsOneWidget); + expect(find.text('releases'), findsOneWidget); + final rootListCountAfterPathInput = sshService.sftp.listCounts['/'] ?? 0; + expect(rootListCountAfterPathInput, rootListCountAfterCreate + 1); + + await tester.tap(_byTooltipLabel('Refresh')); + await tester.pumpAndSettle(); + expect(sshService.sftp.listCounts['/'], rootListCountAfterPathInput + 1); await tester.tap(_byTooltipLabel('Rename').first); await tester.pumpAndSettle(); @@ -393,12 +422,12 @@ void main() { await tester.tap(_byTooltipLabel('Change permissions').first); await tester.pumpAndSettle(); await tester.enterText( - find.byKey(const ValueKey('text-input-Octal permissions')), - '0700', + find.byKey(const ValueKey('text-input-Permissions (octal or symbolic)')), + 'rwx------', ); await tester.tap(find.widgetWithText(SerlinkFilledButton, 'Apply')); await tester.pumpAndSettle(); - expect(find.text('0700'), findsOneWidget); + expect(find.text('rwx------'), findsOneWidget); await tester.tap(_byTooltipLabel('Move').first); await tester.pumpAndSettle(); diff --git a/test/workspace_smoke_test_fakes.dart b/test/workspace_smoke_test_fakes.dart index bb6fdf5..3edbbd6 100644 --- a/test/workspace_smoke_test_fakes.dart +++ b/test/workspace_smoke_test_fakes.dart @@ -110,6 +110,7 @@ class _FakeShellSession implements SshShellSession { class _MutableFakeSftpConnection implements SftpConnection { final Set deniedListPaths = {}; + final Map listCounts = {}; final Map _entries = { '/app.env': SftpEntry( name: 'app.env', @@ -205,6 +206,7 @@ class _MutableFakeSftpConnection implements SftpConnection { @override Future> list(String path) async { + listCounts[path] = (listCounts[path] ?? 0) + 1; if (deniedListPaths.contains(path)) { throw const SftpFailureException( SftpFailure( diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 914aa26..6c2037a 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,11 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + DesktopDropPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopDropPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 13436fc..76efb7c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + desktop_drop file_selector_windows flutter_secure_storage_windows sentry_flutter